- Drop fields and functions nothing reads: job and proc tips, fetch time, PR url/author (and their GraphQL fields), spine summaries, diff::commit, unread data attributes, unused CSS and tokens. - One home each for state wording (naming main as the repo does), lane lookup, unseen, outdated, main's tip, the stop wait and the token header. - Narrow visibility; rename render::View to Filter; queue ranking moves to the CLI, its only user. - Token test covers every route; shared checkout snapshot in the fixture. - Client: errors outside Run/Send are toasts, history only after a good load, a drag stays in one file, the agent marker is a ring. - design.md, the skill and AGENTS.md describe what is built.
442 lines
14 KiB
Rust
442 lines
14 KiB
Rust
mod fixture;
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use branchgraph::proc::{Kind, Procs, Status};
|
|
use branchgraph::state::{self, Anchor, Comment};
|
|
use branchgraph::worktree::Config;
|
|
use branchgraph::{git, review};
|
|
use fixture::{Fixture, git as run};
|
|
|
|
async fn finished(procs: &Procs, branch: &str, kind: Kind) -> Status {
|
|
for _ in 0..200 {
|
|
let status = procs
|
|
.get(branch, kind)
|
|
.unwrap()
|
|
.lock()
|
|
.unwrap()
|
|
.status
|
|
.clone();
|
|
if status != Status::Running {
|
|
return status;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
panic!("{branch} {kind:?} still running after 10 s");
|
|
}
|
|
|
|
fn lane(repo: &gix::Repository, name: &str) -> (git::Graph, git::Lane) {
|
|
let graph = git::build(repo).unwrap();
|
|
let lane = graph.lane(name).unwrap().clone();
|
|
(graph, lane)
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn run_makes_a_worktree_for_a_remote_branch_and_leaves_the_checkout_alone() {
|
|
let f = Fixture::new();
|
|
let c = f.clone.as_path();
|
|
run(c, &["branch", "-D", "feat/old"]);
|
|
std::fs::write(c.join(".env"), "SECRET=1\n").unwrap();
|
|
let before = fixture::checkout_state(c);
|
|
let locals = run(c, &["for-each-ref", "--format=%(refname)", "refs/heads"]);
|
|
|
|
let repo = git::open(c).unwrap();
|
|
let (_, old) = lane(&repo, "feat/old");
|
|
assert_eq!(old.local, None, "remote-only before the run");
|
|
let cfg = Config {
|
|
run: Some("echo running on $BG_BRANCH port $BG_PORT".into()),
|
|
setup: Some("echo setup >> setup.log".into()),
|
|
copy: vec![".env*".into()],
|
|
..Config::default()
|
|
};
|
|
let procs = Arc::new(Procs::default());
|
|
procs
|
|
.start(review::run_job(&repo, &old, &cfg, false).unwrap())
|
|
.unwrap();
|
|
assert_eq!(
|
|
finished(&procs, "feat/old", Kind::Run).await,
|
|
Status::Exited(Some(0))
|
|
);
|
|
|
|
let wt = c.with_file_name("clone.worktrees").join("feat-old");
|
|
assert!(run(c, &["worktree", "list"]).contains(wt.to_str().unwrap()));
|
|
assert_eq!(
|
|
run(&wt, &["rev-parse", "--abbrev-ref", "@{upstream}"]),
|
|
"origin/feat/old"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(wt.join(".env")).unwrap(),
|
|
"SECRET=1\n"
|
|
);
|
|
let lines: Vec<String> = procs
|
|
.get("feat/old", Kind::Run)
|
|
.unwrap()
|
|
.lock()
|
|
.unwrap()
|
|
.lines
|
|
.iter()
|
|
.cloned()
|
|
.collect();
|
|
assert!(
|
|
lines
|
|
.iter()
|
|
.any(|l| l.starts_with("running on feat/old port 2")),
|
|
"{lines:?}"
|
|
);
|
|
|
|
// A second run reuses the worktree and does not set up again.
|
|
let repo = git::open(c).unwrap();
|
|
let (_, old) = lane(&repo, "feat/old");
|
|
procs
|
|
.start(review::run_job(&repo, &old, &cfg, false).unwrap())
|
|
.unwrap();
|
|
finished(&procs, "feat/old", Kind::Run).await;
|
|
assert_eq!(
|
|
std::fs::read_to_string(wt.join("setup.log")).unwrap(),
|
|
"setup\n"
|
|
);
|
|
|
|
assert_eq!(fixture::checkout_state(c), before);
|
|
let after = run(c, &["for-each-ref", "--format=%(refname)", "refs/heads"]);
|
|
assert_eq!(
|
|
after
|
|
.lines()
|
|
.filter(|l| !locals.contains(l))
|
|
.collect::<Vec<_>>(),
|
|
["refs/heads/feat/old"]
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn send_runs_the_agent_in_the_worktree_with_the_brief_and_no_injection() {
|
|
let f = Fixture::new();
|
|
let c = f.clone.as_path();
|
|
let evil = "feat/$(touch${IFS}pwned)";
|
|
run(c, &["branch", evil, "feat/old"]);
|
|
let state = tempfile::tempdir().unwrap();
|
|
let dir = state.path();
|
|
let repo = git::open(c).unwrap();
|
|
let (graph, lane) = lane(&repo, evil);
|
|
let comment = |id, anchor, text: &str| Comment {
|
|
id,
|
|
anchor,
|
|
excerpt: "b".into(),
|
|
text: text.into(),
|
|
tip: lane.tip.to_string(),
|
|
sent: None,
|
|
};
|
|
state::update(dir, evil, |r| {
|
|
r.instruction = "Fix both. Do not push.".into();
|
|
r.comments.push(comment(
|
|
1,
|
|
Anchor::Lines {
|
|
path: "old.txt".into(),
|
|
start: 2,
|
|
end: 2,
|
|
},
|
|
"Rename b to beta.",
|
|
));
|
|
r.comments.push(comment(2, Anchor::Branch, "Add a test."));
|
|
})
|
|
.unwrap();
|
|
let out = f.clone.with_file_name("agent-out");
|
|
let cfg = Config {
|
|
agent: Some(format!(
|
|
r#"pwd > {o}.pwd; echo "$BG_BRANCH" > {o}.branch; cp "$BG_BRIEF" {o}.md"#,
|
|
o = out.display()
|
|
)),
|
|
..Config::default()
|
|
};
|
|
let procs = Arc::new(Procs::default());
|
|
let (job, sent, instruction) =
|
|
review::send_job(&repo, dir, &graph, &lane, &cfg, false).unwrap();
|
|
procs.start(job).unwrap();
|
|
review::mark_sent(dir, evil, &sent, &instruction).unwrap();
|
|
assert_eq!(
|
|
finished(&procs, evil, Kind::Agent).await,
|
|
Status::Exited(Some(0))
|
|
);
|
|
|
|
let read = |ext: &str| std::fs::read_to_string(out.with_extension(ext)).unwrap();
|
|
assert!(
|
|
read("pwd")
|
|
.trim_end()
|
|
.ends_with("clone.worktrees/feat---touch--IFS-pwned-"),
|
|
"{}",
|
|
read("pwd")
|
|
);
|
|
assert_eq!(read("branch").trim_end(), evil);
|
|
let brief = read("md");
|
|
for needle in [
|
|
"old.txt:2 @",
|
|
"Rename b to beta.",
|
|
"the whole branch @",
|
|
"Add a test.",
|
|
"Fix both. Do not push.",
|
|
&lane.tip.to_string(),
|
|
] {
|
|
assert!(brief.contains(needle), "brief lacks {needle:?}:\n{brief}");
|
|
}
|
|
let wt = std::path::PathBuf::from(read("pwd").trim_end());
|
|
assert!(
|
|
!c.join("pwned").exists() && !wt.join("pwned").exists(),
|
|
"the branch name ran as shell"
|
|
);
|
|
assert!(
|
|
state::load(dir, evil).unwrap().drafts().next().is_none(),
|
|
"drafts marked sent"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn stop_sends_sigterm_to_the_main_process() {
|
|
let f = Fixture::new();
|
|
let repo = git::open(&f.clone).unwrap();
|
|
let (_, old) = lane(&repo, "feat/old");
|
|
let cfg = Config {
|
|
run: Some(r#"trap 'echo got-term > "$BG_WORKTREE/term.txt"; exit 0' TERM; echo ready; while true; do sleep 0.1; done"#.into()),
|
|
..Config::default()
|
|
};
|
|
let procs = Arc::new(Procs::default());
|
|
procs
|
|
.start(review::run_job(&repo, &old, &cfg, false).unwrap())
|
|
.unwrap();
|
|
let p = procs.get("feat/old", Kind::Run).unwrap();
|
|
while !p.lock().unwrap().lines.iter().any(|l| l == "ready") {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
procs.stop("feat/old", Kind::Run);
|
|
assert_eq!(
|
|
finished(&procs, "feat/old", Kind::Run).await,
|
|
Status::Exited(Some(0))
|
|
);
|
|
let wt = f.clone.with_file_name("clone.worktrees").join("feat-old");
|
|
assert_eq!(
|
|
std::fs::read_to_string(wt.join("term.txt")).unwrap(),
|
|
"got-term\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_job_refuses_while_one_runs() {
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
rt.block_on(async {
|
|
let f = Fixture::new();
|
|
let job = |cmd: &str| branchgraph::proc::Job {
|
|
branch: "feat/old".into(),
|
|
kind: Kind::Agent,
|
|
cwd: f.clone.clone(),
|
|
env: Vec::new(),
|
|
steps: vec![cmd.into()],
|
|
};
|
|
let procs = Arc::new(Procs::default());
|
|
procs.start(job("sleep 5")).unwrap();
|
|
assert!(procs.start(job("true")).is_err());
|
|
procs.stop("feat/old", Kind::Agent);
|
|
});
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn output_survives_bytes_that_are_not_utf8() {
|
|
let f = Fixture::new();
|
|
let repo = git::open(&f.clone).unwrap();
|
|
let (_, old) = lane(&repo, "feat/old");
|
|
let cfg = Config {
|
|
run: Some(r"printf 'latin1 \351t\351\n'; echo after".into()),
|
|
..Config::default()
|
|
};
|
|
let procs = Arc::new(Procs::default());
|
|
procs
|
|
.start(review::run_job(&repo, &old, &cfg, false).unwrap())
|
|
.unwrap();
|
|
assert_eq!(
|
|
finished(&procs, "feat/old", Kind::Run).await,
|
|
Status::Exited(Some(0))
|
|
);
|
|
let lines: Vec<String> = procs
|
|
.get("feat/old", Kind::Run)
|
|
.unwrap()
|
|
.lock()
|
|
.unwrap()
|
|
.lines
|
|
.iter()
|
|
.cloned()
|
|
.collect();
|
|
assert!(lines.iter().any(|l| l.starts_with("latin1 ")), "{lines:?}");
|
|
assert!(lines.iter().any(|l| l == "after"), "{lines:?}");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn send_after_run_reuses_the_worktree_from_a_stale_snapshot() {
|
|
let f = Fixture::new();
|
|
let c = f.clone.as_path();
|
|
run(c, &["branch", "-D", "feat/old"]);
|
|
let state = tempfile::tempdir().unwrap();
|
|
let repo = git::open(c).unwrap();
|
|
// One snapshot for both, as when Send follows Run within a poll.
|
|
let (graph, old) = lane(&repo, "feat/old");
|
|
let cfg = Config {
|
|
run: Some("true".into()),
|
|
agent: Some("pwd".into()),
|
|
..Config::default()
|
|
};
|
|
let procs = Arc::new(Procs::default());
|
|
procs
|
|
.start(review::run_job(&repo, &old, &cfg, false).unwrap())
|
|
.unwrap();
|
|
finished(&procs, "feat/old", Kind::Run).await;
|
|
state::update(state.path(), "feat/old", |r| r.instruction = "Go.".into()).unwrap();
|
|
let (job, _, _) = review::send_job(&repo, state.path(), &graph, &old, &cfg, false).unwrap();
|
|
assert_eq!(
|
|
job.cwd,
|
|
c.with_file_name("clone.worktrees").join("feat-old")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn concurrent_comment_writes_lose_nothing() {
|
|
let state = tempfile::tempdir().unwrap();
|
|
let dir = state.path().to_path_buf();
|
|
let writers: Vec<_> = (0..4)
|
|
.map(|w| {
|
|
let dir = dir.clone();
|
|
std::thread::spawn(move || {
|
|
for i in 0..25 {
|
|
let c = Comment {
|
|
id: 0,
|
|
anchor: Anchor::Branch,
|
|
excerpt: String::new(),
|
|
text: format!("{w}-{i}"),
|
|
tip: "t".into(),
|
|
sent: None,
|
|
};
|
|
state::update(&dir, "b", |r| r.add(c)).unwrap();
|
|
}
|
|
})
|
|
})
|
|
.collect();
|
|
for t in writers {
|
|
t.join().unwrap();
|
|
}
|
|
let review = state::load(&dir, "b").unwrap();
|
|
let mut ids: Vec<u64> = review.comments.iter().map(|c| c.id).collect();
|
|
ids.sort();
|
|
ids.dedup();
|
|
assert_eq!((review.comments.len(), ids.len()), (100, 100));
|
|
}
|
|
|
|
#[test]
|
|
fn settings_travel_with_the_branch_and_saving_writes_only_the_worktree_file() {
|
|
let f = Fixture::new();
|
|
let c = f.clone.as_path();
|
|
let cfg = |name: &str| {
|
|
let repo = git::open(c).unwrap();
|
|
let (_, l) = lane(&repo, name);
|
|
branchgraph::worktree::config(&repo, &l).unwrap().run
|
|
};
|
|
|
|
// Main's file is the fallback for a branch without one.
|
|
fixture::commit(
|
|
c,
|
|
".branchgraph",
|
|
"[branchgraph]\n\trun = cd recordum && bash localdev.sh\n",
|
|
"add branchgraph config",
|
|
);
|
|
run(c, &["push", "-q", "origin", "main"]);
|
|
assert_eq!(
|
|
cfg("feat/old").as_deref(),
|
|
Some("cd recordum && bash localdev.sh")
|
|
);
|
|
|
|
// A branch's committed file wins: settings change with the branch.
|
|
run(c, &["checkout", "-q", "-b", "feat/custom"]);
|
|
fixture::commit(
|
|
c,
|
|
".branchgraph",
|
|
"[branchgraph]\n\trun = bash localdev.sh --custom\n",
|
|
"custom run",
|
|
);
|
|
run(c, &["push", "-q", "-u", "origin", "feat/custom"]);
|
|
run(c, &["checkout", "-q", "main"]);
|
|
run(c, &["branch", "-D", "feat/custom"]);
|
|
assert_eq!(
|
|
cfg("feat/custom").as_deref(),
|
|
Some("bash localdev.sh --custom")
|
|
);
|
|
|
|
// Saving from the page writes the file in the branch's worktree, nothing else.
|
|
let repo = git::open(c).unwrap();
|
|
let (_, custom) = lane(&repo, "feat/custom");
|
|
let before = run(c, &["rev-parse", "origin/feat/custom"]);
|
|
let text = "[branchgraph]\n\trun = bash localdev.sh --edited\n";
|
|
let parsed = Config::parse(text).unwrap();
|
|
let (path, _) = branchgraph::worktree::save_config(&repo, &custom, text, &parsed).unwrap();
|
|
let wt = path.parent().unwrap();
|
|
assert_eq!(
|
|
run(wt, &["status", "--porcelain"]),
|
|
"M .branchgraph",
|
|
"changed in the worktree, not committed"
|
|
);
|
|
assert_eq!(run(c, &["rev-parse", "origin/feat/custom"]), before);
|
|
assert_eq!(
|
|
cfg("feat/custom").as_deref(),
|
|
Some("bash localdev.sh --edited"),
|
|
"the worktree file counts before a commit"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn copying_never_writes_through_a_symlink_the_branch_committed() {
|
|
let f = Fixture::new();
|
|
let c = f.clone.as_path();
|
|
let outside = f.clone.with_file_name("outside");
|
|
std::fs::create_dir_all(&outside).unwrap();
|
|
// The branch commits `config` as a symlink to a directory outside.
|
|
run(c, &["checkout", "-q", "-b", "feat/link", "main"]);
|
|
std::os::unix::fs::symlink(&outside, c.join("config")).unwrap();
|
|
// And `.env` itself as a dangling link: copying would create its target.
|
|
std::os::unix::fs::symlink(outside.join("leaf.env"), c.join(".env")).unwrap();
|
|
run(c, &["add", "config", ".env"]);
|
|
run(c, &["commit", "-qm", "config and .env are links"]);
|
|
run(c, &["push", "-q", "-u", "origin", "feat/link"]);
|
|
run(c, &["checkout", "-q", "main"]);
|
|
run(c, &["branch", "-D", "feat/link"]);
|
|
// Main's checkout has an untracked file the pattern names.
|
|
std::fs::create_dir_all(c.join("config")).unwrap();
|
|
std::fs::write(c.join("config/local.env"), "SECRET=1\n").unwrap();
|
|
std::fs::write(c.join(".env"), "SECRET=1\n").unwrap();
|
|
|
|
let repo = git::open(c).unwrap();
|
|
let (_, link) = lane(&repo, "feat/link");
|
|
let cfg = Config {
|
|
run: Some("true".into()),
|
|
copy: vec!["config/*.env".into(), ".env".into()],
|
|
..Config::default()
|
|
};
|
|
let procs = Arc::new(Procs::default());
|
|
procs
|
|
.start(review::run_job(&repo, &link, &cfg, false).unwrap())
|
|
.unwrap();
|
|
finished(&procs, "feat/link", Kind::Run).await;
|
|
assert!(
|
|
!outside.join("local.env").exists() && !outside.join("leaf.env").exists(),
|
|
"nothing written outside the checkout"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_branch_cannot_copy_files_from_outside_the_checkout() {
|
|
for bad in ["../../.ssh/*", "/etc/passwd", "a/../../b"] {
|
|
let text = format!("[branchgraph]\n\tcopy = {bad}\n");
|
|
assert!(
|
|
branchgraph::worktree::Config::parse(&text).is_err(),
|
|
"{bad} refused"
|
|
);
|
|
}
|
|
let ok =
|
|
branchgraph::worktree::Config::parse("[branchgraph]\n\tcopy = .env* config/local.toml\n")
|
|
.unwrap();
|
|
assert_eq!(ok.copy, [".env*", "config/local.toml"]);
|
|
}
|