Files
branchgraph/tests/review.rs
Jean-Gabriel Gill-Couture e03a92c570 Remove dead code, merge duplicated logic, match docs to the code
- 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.
2026-10-09 12:24:24 -04:00

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"]);
}