//! End to end against a real git repository.
//!
//! Most tests here run on git alone. A few use local command fakes to exercise
//! remote-write boundaries without a network. Together they cover mechanisms
//! that unit tests cannot reach, in particular the commit message rewrite,
//! which shells out to `git filter-branch` and calls back into this same binary.
use std::path::{Path, PathBuf};
use std::process::Command;
use spar::agent::Agent;
use spar::config::CommandPart;
use spar::config::{self, Config, Followups};
use spar::model::{Complexity, Followup, Issue, IssueRun, PlanItem, PrView, Risk, Status};
use spar::repo::Repo;
use spar::review::{drop_uncommitted, file_followup, park, run_issue, snapshot, undo_edits};
const SPAR_BIN: &str = env!("CARGO_BIN_EXE_spar");
const TWO_AGENTS: &str = "\
[agents.a]
command = [\"true\"]
[agents.b]
command = [\"true\"]
";
struct Fixture {
dir: PathBuf,
work: PathBuf,
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn git(cwd: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.unwrap_or_else(|e| panic!("git {args:?}: {e}"));
assert!(
out.status.success(),
"git {args:?} failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn unique(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("spar-it-{tag}-{}-{nanos}-{n}", std::process::id()))
}
/// A work tree with an `origin` behind it, so `origin/main..HEAD` resolves.
fn repo(tag: &str) -> Fixture {
let dir = unique(tag);
let origin = dir.join("origin.git");
let work = dir.join("work");
std::fs::create_dir_all(&origin).unwrap();
std::fs::create_dir_all(&work).unwrap();
git(&origin, &["init", "--bare", "-b", "main"]);
git(&work, &["init", "-b", "main"]);
git(&work, &["config", "user.email", "spar@example.invalid"]);
git(&work, &["config", "user.name", "spar test"]);
git(&work, &["config", "commit.gpgsign", "false"]);
std::fs::write(work.join("README.md"), "seed\n").unwrap();
git(&work, &["add", "."]);
git(&work, &["commit", "-m", "seed"]);
git(
&work,
&["remote", "add", "origin", origin.to_str().unwrap()],
);
git(&work, &["push", "-u", "origin", "main"]);
Fixture { dir, work }
}
fn cfg() -> Config {
config::parse(TWO_AGENTS).unwrap()
}
fn commit(work: &Path, file: &str, body: &str, message: &str) {
std::fs::write(work.join(file), body).unwrap();
git(work, &["add", "."]);
git(work, &["commit", "-m", message]);
}
fn add_submodule(fx: &Fixture, name: &str) -> PathBuf {
let source = fx.dir.join(format!("{name}-source"));
std::fs::create_dir_all(&source).unwrap();
git(&source, &["init", "-b", "main"]);
git(&source, &["config", "user.email", "spar@example.invalid"]);
git(&source, &["config", "user.name", "spar test"]);
git(&source, &["config", "commit.gpgsign", "false"]);
std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
std::fs::write(source.join(".gitignore"), "generated/\n").unwrap();
git(&source, &["add", "."]);
git(&source, &["commit", "-m", "seed submodule"]);
git(
&fx.work,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"add",
source.to_str().unwrap(),
name,
],
);
git(&fx.work, &["commit", "-m", "add submodule"]);
git(&fx.work, &["push", "-q", "origin", "main"]);
source
}
fn pr(number: i64, head: &str) -> PrView {
PrView {
number,
url: format!("https://example.invalid/pull/{number}"),
title: format!("PR {number}"),
body: format!("What PR {number} says it does."),
head_ref_name: head.to_string(),
base_ref_name: "main".into(),
state: "OPEN".into(),
closing_issues_references: Vec::new(),
is_cross_repository: false,
}
}
fn messages(work: &Path) -> String {
git(work, &["log", "origin/main..HEAD", "--format=%B"])
}
// ---------------------------------------------------------------------------
// Opening a repository
// ---------------------------------------------------------------------------
#[test]
fn a_real_repository_opens() {
let fx = repo("open");
assert!(Repo::open(&fx.work, &cfg()).is_ok());
}
#[test]
fn a_directory_that_is_not_a_repository_is_refused() {
let dir = unique("notarepo");
std::fs::create_dir_all(&dir).unwrap();
let err = Repo::open(&dir, &cfg()).unwrap_err().to_string();
assert!(err.contains("not a git repository"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_default_branch_comes_from_origin_head_not_an_assumption() {
let fx = repo("defaultbranch");
git(
&fx.work,
&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/main",
],
);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
assert_eq!("main", repo.default_branch("some-other-guess"));
}
/// The reviewer is told to read the ref spar reads.
///
/// In a linked worktree the local `main` is whatever the primary checkout last
/// had. Behind origin, a reviewer told to diff against it sees every upstream
/// commit in between as part of the pull request, and raises findings about
/// code the author never touched.
#[test]
fn the_base_a_prompt_names_is_the_base_spar_compares_against() {
let fx = repo("baseref");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
commit(
&fx.work,
"upstream.rs",
"upstream\n",
"somebody else's work",
);
git(&fx.work, &["push", "origin", "main"]);
// Local main left behind, which is the ordinary state of a worktree run.
git(&fx.work, &["reset", "--hard", "HEAD~1"]);
assert_eq!("origin/main", repo.base_ref(&fx.work, "main"));
assert_ne!(
git(&fx.work, &["rev-parse", "main"]),
git(&fx.work, &["rev-parse", "origin/main"]),
"the two refs have to differ for this to prove anything"
);
}
#[test]
fn a_repository_with_no_origin_head_falls_back_to_the_configured_branch() {
let fx = repo("nohead");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
assert_eq!("trunk", repo.default_branch("trunk"));
}
// ---------------------------------------------------------------------------
// The style gate over commit messages
// ---------------------------------------------------------------------------
/// The plumbing subcommand git calls. Verified directly, because a bug here is
/// invisible until it has already rewritten somebody's history.
#[test]
fn the_scrub_filter_subcommand_cleans_stdin() {
use std::io::Write;
use std::process::Stdio;
let mut child = Command::new(SPAR_BIN)
.arg("scrub-filter")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(b"Add retry logic \xE2\x80\x94 with backoff\n\nCo-Authored-By: Claude <x@y.z>\n")
.unwrap();
let out = child.wait_with_output().unwrap();
let text = String::from_utf8_lossy(&out.stdout).into_owned();
assert!(out.status.success());
assert!(text.contains("Add retry logic"), "{text}");
assert!(!text.contains('\u{2014}'), "{text}");
assert!(!text.contains("Co-Authored-By"), "{text}");
}
#[test]
fn an_offending_commit_message_is_rewritten_in_place() {
let fx = repo("rewrite");
std::env::set_var("SPAR_SELF_BIN", SPAR_BIN);
commit(
&fx.work,
"a.txt",
"one\n",
"Add the parser \u{2014} finally\n\nCo-Authored-By: Claude Opus <noreply@anthropic.com>",
);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
repo.rewrite_commits_if_needed(&fx.work, "main", None)
.unwrap();
let log = messages(&fx.work);
assert!(!log.contains('\u{2014}'), "{log}");
assert!(!log.contains("Co-Authored-By"), "{log}");
assert!(
log.contains("Add the parser"),
"the message must survive, not vanish: {log}"
);
}
#[test]
fn a_clean_history_is_left_completely_alone() {
let fx = repo("clean");
std::env::set_var("SPAR_SELF_BIN", SPAR_BIN);
commit(&fx.work, "a.txt", "one\n", "Add the parser");
let before = git(&fx.work, &["rev-parse", "HEAD"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
repo.rewrite_commits_if_needed(&fx.work, "main", None)
.unwrap();
assert_eq!(
before,
git(&fx.work, &["rev-parse", "HEAD"]),
"no rewrite, no new sha"
);
}
#[test]
fn only_the_offending_commit_of_several_loses_its_dash() {
let fx = repo("multi");
std::env::set_var("SPAR_SELF_BIN", SPAR_BIN);
commit(&fx.work, "a.txt", "one\n", "First commit, perfectly fine");
commit(
&fx.work,
"b.txt",
"two\n",
"Second commit \u{2013} not fine",
);
commit(&fx.work, "c.txt", "three\n", "Third commit, also fine");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
repo.rewrite_commits_if_needed(&fx.work, "main", None)
.unwrap();
let log = messages(&fx.work);
assert!(!log.contains('\u{2013}'), "{log}");
for text in ["First commit", "Second commit", "Third commit"] {
assert!(log.contains(text), "{text} was lost:\n{log}");
}
assert_eq!(
3,
git(&fx.work, &["rev-list", "origin/main..HEAD"])
.lines()
.count()
);
}
/// The commits somebody else pushed are not spar's to rewrite.
///
/// A resumed pull request, or a check-in on somebody's branch, reaches the
/// scrub with the author's own commits in the range. Rewriting one changes a
/// SHA that is already published: their local branch diverges from the remote
/// and their next push is rejected. The style gate is for what spar writes.
#[test]
fn a_commit_that_was_already_on_the_branch_keeps_its_sha() {
let fx = repo("floor");
std::env::set_var("SPAR_SELF_BIN", SPAR_BIN);
commit(
&fx.work,
"a.txt",
"one\n",
"Their commit \u{2014} written by a person",
);
let theirs = git(&fx.work, &["rev-parse", "HEAD"]);
commit(
&fx.work,
"b.txt",
"two\n",
"spar's commit \u{2013} not fine",
);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
repo.rewrite_commits_if_needed(&fx.work, "main", Some(theirs.trim()))
.unwrap();
let log = messages(&fx.work);
assert!(
log.contains("Their commit \u{2014} written by a person"),
"a person's message was rewritten:\n{log}"
);
assert!(
!log.contains("spar's commit \u{2013}"),
"spar's own message was not scrubbed:\n{log}"
);
assert_eq!(
theirs.trim(),
git(&fx.work, &["rev-parse", "HEAD~1"]).trim(),
"the published sha changed, which is the divergence this prevents"
);
}
#[test]
fn the_tree_is_unchanged_by_a_message_rewrite() {
let fx = repo("tree");
std::env::set_var("SPAR_SELF_BIN", SPAR_BIN);
commit(
&fx.work,
"a.txt",
"content that must survive\n",
"Work \u{2014} done",
);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
repo.rewrite_commits_if_needed(&fx.work, "main", None)
.unwrap();
assert_eq!(
"content that must survive\n",
std::fs::read_to_string(fx.work.join("a.txt")).unwrap()
);
}
// ---------------------------------------------------------------------------
// Change detection
// ---------------------------------------------------------------------------
#[test]
fn an_untouched_branch_has_no_changes() {
let fx = repo("nochanges");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
assert!(!repo.has_changes(&fx.work, "main"));
}
#[test]
fn a_committed_branch_has_changes_and_a_diffstat() {
let fx = repo("changes");
commit(&fx.work, "a.txt", "one\n", "Add a file");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
assert!(repo.has_changes(&fx.work, "main"));
let stat = repo.diff_stat(&fx.work, "main");
assert!(stat.contains("1 file changed"), "{stat}");
assert!(
stat.lines().count() == 1,
"the PR body wants one line, not a file list: {stat}"
);
}
// ---------------------------------------------------------------------------
// Custody follows the commit that landed
// ---------------------------------------------------------------------------
/// The review prompt says not to write, and this is what makes that true. A
/// commit made while reviewing would be a commit its own author reviews next
/// round, which is the one thing the alternating loop exists to prevent.
#[test]
fn a_commit_made_while_reviewing_is_rolled_back() {
let fx = repo("reviewcommit");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let before = snapshot(&repo, &fx.work);
commit(
&fx.work,
"README.md",
"reviewer was here\n",
"Sneak a fix in",
);
let during = snapshot(&repo, &fx.work);
assert!(during.landed_over(&before), "the fixture committed nothing");
let after = undo_edits(&repo, &fx.work, &before);
assert_eq!(before, after);
assert_eq!(
"seed\n",
std::fs::read_to_string(fx.work.join("README.md")).unwrap()
);
}
/// The review prompt asks for a scratch file when a claim needs running to
/// check it. Counting one as a mutation would roll back every review that did
/// as it was told, and throw away the scratch file with it.
#[test]
fn a_scratch_file_written_while_reviewing_is_not_a_mutation() {
let fx = repo("scratch");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let before = snapshot(&repo, &fx.work);
std::fs::write(fx.work.join("check.sh"), "echo hi\n").unwrap();
assert_eq!(before, snapshot(&repo, &fx.work));
// And the rollback a real mutation triggers leaves it where it is.
std::fs::write(fx.work.join("README.md"), "edited\n").unwrap();
git(&fx.work, &["commit", "-m", "Sneak a fix in", "README.md"]);
undo_edits(&repo, &fx.work, &before);
assert!(fx.work.join("check.sh").exists());
}
/// An edit left in the working tree is never pushed, so it must not be carried
/// into the round that follows either.
#[test]
fn an_uncommitted_edit_made_while_reviewing_is_rolled_back() {
let fx = repo("reviewdirty");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let before = snapshot(&repo, &fx.work);
std::fs::write(fx.work.join("README.md"), "reviewer was here\n").unwrap();
let during = snapshot(&repo, &fx.work);
assert!(during.dirty);
assert!(!during.landed_over(&before));
assert_eq!(before, undo_edits(&repo, &fx.work, &before));
}
/// A fix left in the working tree is code the next review reads and the pull
/// request does not have, so it goes. What the call did commit stays.
#[test]
fn a_fix_left_uncommitted_does_not_reach_the_next_round() {
let fx = repo("dirtyfix");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
commit(&fx.work, "a.txt", "one\n", "Fix the finding");
let committed = snapshot(&repo, &fx.work);
std::fs::write(fx.work.join("a.txt"), "two\n").unwrap();
std::fs::write(fx.work.join("check.sh"), "echo hi\n").unwrap();
assert_eq!(committed, drop_uncommitted(&repo, &fx.work));
assert_eq!(
"one\n",
std::fs::read_to_string(fx.work.join("a.txt")).unwrap()
);
assert!(
fx.work.join("check.sh").exists(),
"scratch files are not ours"
);
}
/// A shared checkout is the user's own, and nothing here can tell an agent's
/// leftovers from an edit somebody made while a call was running, so what the
/// rollback throws away is recoverable. The stash stack is left alone: it
/// belongs to whoever is working in the repository.
#[test]
fn what_the_rollback_throws_away_is_saved_first() {
let fx = repo("parked");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let before = snapshot(&repo, &fx.work);
std::fs::write(fx.work.join("README.md"), "somebody was editing this\n").unwrap();
// The rollback parks the same way; doing it here is how the test gets hold
// of the handle the log prints.
let parked = park(&repo, &fx.work).expect("a dirty tree has something to save");
assert_eq!(before, undo_edits(&repo, &fx.work, &before));
assert_eq!(
"seed\n",
std::fs::read_to_string(fx.work.join("README.md")).unwrap()
);
assert!(git(&fx.work, &["stash", "list"]).trim().is_empty());
git(&fx.work, &["stash", "apply", &parked]);
assert_eq!(
"somebody was editing this\n",
std::fs::read_to_string(fx.work.join("README.md")).unwrap()
);
}
/// The `fix_myself` bug, at the level the loop reads. A call that returns
/// successfully having committed nothing leaves the head with whoever wrote it,
/// and `landed_over` is what says so.
#[test]
fn a_call_that_returns_without_committing_leaves_the_head_alone() {
let fx = repo("nofix");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let before = snapshot(&repo, &fx.work);
assert!(!snapshot(&repo, &fx.work).landed_over(&before));
commit(&fx.work, "a.txt", "one\n", "Fix the finding");
assert!(snapshot(&repo, &fx.work).landed_over(&before));
}
// ---------------------------------------------------------------------------
// Worktrees and the branch ledger
// ---------------------------------------------------------------------------
#[test]
fn a_worktree_is_created_on_the_base_branch_and_recorded() {
let fx = repo("worktree");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, branch) = repo.worktree_add(42, "main").unwrap();
assert!(path.is_dir(), "{}", path.display());
assert_eq!("issue-42", branch);
assert!(
path.join("README.md").is_file(),
"the worktree has the base branch content"
);
assert!(
repo.known_branches().contains_key("issue-42"),
"an unrecorded branch is never cleaned"
);
repo.worktree_remove(42);
assert!(!path.is_dir());
}
#[test]
fn ordinary_release_does_not_prune_a_missing_personal_worktree() {
let fx = repo("personal-worktree-admin");
let personal = fx.dir.join("personal");
git(
&fx.work,
&[
"worktree",
"add",
"--detach",
personal.to_str().unwrap(),
"main",
],
);
commit(&personal, "personal.txt", "keep me\n", "personal recovery");
let recovery = git(&personal, &["rev-parse", "HEAD"]);
let admin = std::fs::canonicalize(git(&personal, &["rev-parse", "--git-dir"]).trim()).unwrap();
std::fs::remove_dir_all(&personal).unwrap();
git(&fx.work, &["config", "gc.worktreePruneExpire", "now"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(42, "main").unwrap();
assert!(repo.worktree_remove(42));
assert!(!path.exists());
assert!(
admin.exists(),
"the personal worktree registration was pruned"
);
assert_eq!(
recovery.trim(),
std::fs::read_to_string(admin.join("HEAD")).unwrap().trim()
);
}
#[test]
fn a_branch_prefix_namespaces_the_branch() {
let fx = repo("prefix");
let mut c = cfg();
c.loop_cfg.branch_prefix = "spar/".into();
let repo = Repo::open(&fx.work, &c).unwrap();
assert_eq!("spar/issue-7", repo.branch_for_issue(7));
assert_eq!("spar/pr-7", repo.branch_for_pr(7));
let (_, branch) = repo.worktree_add(7, "main").unwrap();
assert_eq!("spar/issue-7", branch);
repo.worktree_remove(7);
}
/// The data loss guard. `issue-9` here belongs to a person, not to spar, and
/// even `--all` must not remove it. Ownership comes from the ledger, never from
/// the name, because `issue-9` is exactly what somebody would call a branch.
#[test]
fn a_branch_spar_did_not_create_survives_even_a_forced_clean() {
let fx = repo("dataloss");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
git(&fx.work, &["branch", "issue-9"]);
git(&fx.work, &["branch", "my-own-work"]);
let removed = repo.prune_worktrees(true);
assert!(removed.is_empty(), "{removed:?}");
let branches = git(
&fx.work,
&["for-each-ref", "refs/heads/", "--format=%(refname:short)"],
);
assert!(branches.contains("issue-9"), "{branches}");
assert!(branches.contains("my-own-work"), "{branches}");
}
#[test]
fn a_recorded_branch_is_removed_by_a_forced_clean() {
let fx = repo("forced");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, branch) = repo.worktree_add(11, "main").unwrap();
assert!(path.is_dir());
let removed = repo.prune_worktrees(true);
assert!(!removed.is_empty(), "nothing was cleaned");
assert!(!path.is_dir(), "the worktree directory survived");
let branches = git(
&fx.work,
&["for-each-ref", "refs/heads/", "--format=%(refname:short)"],
);
assert!(!branches.contains(&branch), "{branches}");
assert!(
!repo.known_branches().contains_key(&branch),
"the record outlived the branch"
);
}
#[test]
fn a_recorded_branch_already_deleted_by_hand_is_forgotten() {
let fx = repo("forget");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
repo.record_branch("issue-99", "issue", 99);
repo.prune_worktrees(false);
assert!(!repo.known_branches().contains_key("issue-99"));
}
// ---------------------------------------------------------------------------
// Splitting
// ---------------------------------------------------------------------------
/// A part branch has to go through `record_branch`, or `prune_branches` can
/// never clean it up: ownership comes from the ledger, never from the name.
#[test]
fn a_split_part_gets_its_own_branch_off_the_base_and_is_recorded() {
let fx = repo("split-part");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
commit(&fx.work, "a.rs", "one\n", "on main");
git(&fx.work, &["push", "origin", "main"]);
let (path, branch) = repo.worktree_for_split(12, 1, "origin/main").unwrap();
assert_eq!("split-12-1", branch);
assert!(path.is_dir(), "{}", path.display());
assert!(path.join("a.rs").is_file(), "it starts from the base");
assert!(repo.known_branches().contains_key("split-12-1"));
// Its own namespace, so it cannot collide with the branch of the issue that
// happens to share the parent's number.
assert_ne!(repo.branch_for_issue(12), branch);
assert_ne!(repo.branch_for_pr(12), branch);
}
#[test]
fn a_branch_prefix_namespaces_a_part_branch_too() {
let fx = repo("split-prefix");
let mut c = cfg();
c.loop_cfg.branch_prefix = "spar/".into();
let repo = Repo::open(&fx.work, &c).unwrap();
assert_eq!("spar/split-12-2", repo.branch_for_split(12, 2));
let (dir, branch) = repo.worktree_for_split(12, 2, "main").unwrap();
assert_eq!("spar/split-12-2", branch);
repo.release_split_worktree(&dir, &branch);
}
/// Splitting the same pull request again must not land on the names the first
/// run used. A part's remote branch belongs to its pull request after the first
/// run, even when its local worktree and branch are gone.
#[test]
fn a_second_split_never_reuses_the_first_run_s_branches() {
let fx = repo("split-again");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
commit(&fx.work, "a.rs", "one\n", "on main");
git(&fx.work, &["push", "origin", "main"]);
let (first_dir, first) = repo.worktree_for_split(12, 1, "origin/main").unwrap();
commit(&first_dir, "part.rs", "the slice\n", "part one");
let landed = git(&first_dir, &["rev-parse", "HEAD"]);
git(&first_dir, &["push", "origin", &format!("HEAD:{first}")]);
// The branch is gone locally, the way it is after `clean`, and still on
// origin, where the part's pull request points at it.
git(
&fx.work,
&[
"worktree",
"remove",
"--force",
&first_dir.display().to_string(),
],
);
git(&fx.work, &["branch", "-D", &first]);
let (_, second) = repo.worktree_for_split(12, 1, "origin/main").unwrap();
assert_ne!(
first, second,
"the second split took the first one's branch"
);
assert_eq!(
landed,
git(&fx.work, &["rev-parse", &format!("origin/{first}")]),
"the branch behind the first part moved"
);
assert!(repo.known_branches().contains_key(&second));
}
/// A free name can be taken after allocation and before push. Split branches
/// use a create-only lease so that race rejects the push instead of moving the
/// ref another pull request may already own, even when it could fast-forward.
#[test]
fn a_split_push_never_rewrites_a_branch_that_appeared_after_allocation() {
let fx = repo("split-push-race");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
commit(&fx.work, "base.rs", "base\n", "on main");
git(&fx.work, &["push", "origin", "main"]);
let (dir, branch) = repo.worktree_for_split(12, 1, "origin/main").unwrap();
commit(&dir, "part.rs", "the slice\n", "part one");
let theirs = git(&fx.work, &["rev-parse", "HEAD"]);
git(&fx.work, &["push", "origin", &format!("HEAD:{branch}")]);
let error = repo.push_split_branch(&dir, &branch).unwrap_err();
assert!(
error.to_string().contains("Nothing was overwritten"),
"{error}"
);
git(&fx.work, &["fetch", "origin", &branch]);
assert_eq!(
theirs,
git(&fx.work, &["rev-parse", &format!("origin/{branch}")])
);
}
/// The lease has to name the head spar reviewed, not the tracking ref.
///
/// The tracking ref is shared across every worktree and moves on any `git
/// fetch`. The editing agent is a real CLI with shell access, and fetching is
/// an ordinary thing for it to do, so a bare `--force-with-lease` can be
/// satisfied by a head spar never read and the push then drops somebody's
/// commit.
#[test]
fn a_fetch_during_a_round_does_not_let_the_push_drop_a_commit() {
let fx = repo("lease");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
commit(&fx.work, "base.rs", "base\n", "on main");
git(&fx.work, &["push", "origin", "main"]);
git(&fx.work, &["push", "origin", "HEAD:feature"]);
let reviewed = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
// Somebody pushes to the branch, from their own checkout.
let theirs = fx.dir.join("theirs");
git(
&fx.dir,
&[
"clone",
"-b",
"feature",
fx.dir.join("origin.git").to_str().unwrap(),
theirs.to_str().unwrap(),
],
);
git(&theirs, &["config", "user.email", "them@example.invalid"]);
git(&theirs, &["config", "user.name", "them"]);
git(&theirs, &["config", "commit.gpgsign", "false"]);
commit(&theirs, "theirs.rs", "theirs\n", "their commit");
git(&theirs, &["push", "origin", "HEAD:feature"]);
let head_they_pushed = git(&theirs, &["rev-parse", "HEAD"]);
// The agent fetches while it works, which is what moves the tracking ref
// out from under the implicit lease.
git(&fx.work, &["fetch", "origin"]);
commit(&fx.work, "ours.rs", "ours\n", "our commit");
let error = repo
.push(&fx.work, "feature", Some(&reviewed))
.unwrap_err()
.to_string();
assert!(error.contains("could not push"), "{error}");
git(&fx.work, &["fetch", "origin", "feature"]);
assert_eq!(
head_they_pushed,
git(&fx.work, &["rev-parse", "origin/feature"]),
"their commit was dropped from the branch"
);
}
/// The other half: a HEAD that no longer contains the reviewed head is a reset
/// or a rebase in the worktree, and the remote being exactly where spar left it
/// means the lease cannot see it.
#[test]
fn a_head_that_dropped_the_reviewed_commit_is_refused_before_the_push() {
let fx = repo("lease-local");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
commit(&fx.work, "base.rs", "base\n", "on main");
git(&fx.work, &["push", "origin", "main"]);
commit(&fx.work, "reviewed.rs", "reviewed\n", "reviewed commit");
git(&fx.work, &["push", "origin", "HEAD:feature"]);
let reviewed = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
git(&fx.work, &["reset", "--hard", "HEAD~1"]);
commit(&fx.work, "other.rs", "other\n", "a different commit");
let error = repo
.push(&fx.work, "feature", Some(&reviewed))
.unwrap_err()
.to_string();
assert!(
error.contains("does not contain the reviewed head"),
"{error}"
);
assert!(error.contains(&reviewed), "{error}");
}
/// A pushed branch is a durable retry guard when the parent comment or pull
/// request creation failed after the push.
#[test]
fn a_remote_split_branch_marks_the_parent_as_started() {
let fx = repo("split-started");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (dir, branch) = repo.worktree_for_split(12, 1, "origin/main").unwrap();
commit(&dir, "part.rs", "the slice\n", "part one");
repo.push_split_branch(&dir, &branch).unwrap();
assert!(repo.has_remote_split_branch(12).unwrap());
assert!(!repo.has_remote_split_branch(13).unwrap());
}
/// A part that will not stand on its own is dropped, and nothing was pushed at
/// that point, so it has to leave no trace anywhere but the log.
#[test]
fn a_dropped_part_takes_its_branch_and_its_record_with_it() {
let fx = repo("split-drop");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, branch) = repo.worktree_for_split(12, 1, "main").unwrap();
repo.release_split_worktree(&path, &branch);
assert!(!path.is_dir(), "the worktree survived");
let branches = git(
&fx.work,
&["for-each-ref", "refs/heads/", "--format=%(refname:short)"],
);
assert!(!branches.contains(&branch), "{branches}");
assert!(!repo.known_branches().contains_key(&branch));
}
#[test]
fn an_exact_mechanical_slice_can_be_discarded() {
let fx = repo("split-disposable-slice");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, branch) = repo.worktree_for_split(12, 1, "main").unwrap();
commit(&path, "part.rs", "slice\n", "mechanical slice");
let disposable = git(&path, &["rev-parse", "HEAD"]);
assert!(repo.discard_split_worktree(&path, &branch, disposable.trim()));
assert!(!path.exists());
assert!(git(&fx.work, &["branch", "--list", &branch]).is_empty());
assert!(!repo.known_branches().contains_key(&branch));
}
#[test]
fn a_disposable_slice_with_a_reflog_only_commit_is_retained() {
let fx = repo("split-disposable-reflog");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, branch) = repo.worktree_for_split(12, 1, "main").unwrap();
commit(&path, "part.rs", "slice\n", "mechanical slice");
let disposable = git(&path, &["rev-parse", "HEAD"]);
commit(&path, "recovery.rs", "keep me\n", "recovery commit");
let recovery = git(&path, &["rev-parse", "HEAD"]);
git(&path, &["reset", "--hard", disposable.trim()]);
assert!(!repo.discard_split_worktree(&path, &branch, disposable.trim()));
assert!(path.exists());
assert_eq!(disposable, git(&path, &["rev-parse", "HEAD"]));
git(
&path,
&["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
);
assert!(repo.known_branches().contains_key(&branch));
}
#[test]
fn a_dirty_disposable_slice_is_retained() {
let fx = repo("split-disposable-dirty");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, branch) = repo.worktree_for_split(12, 1, "main").unwrap();
commit(&path, "part.rs", "slice\n", "mechanical slice");
let disposable = git(&path, &["rev-parse", "HEAD"]);
std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
assert!(!repo.discard_split_worktree(&path, &branch, disposable.trim()));
assert!(path.exists());
assert_eq!(
"keep me\n",
std::fs::read_to_string(path.join("recovery.txt")).unwrap()
);
assert!(repo.known_branches().contains_key(&branch));
}
#[test]
fn an_unpushed_split_commit_survives_release() {
let fx = repo("split-release-recovery");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, branch) = repo.worktree_for_split(12, 1, "main").unwrap();
commit(&path, "recovery.rs", "keep me\n", "local split recovery");
let before = git(&path, &["rev-parse", "HEAD"]);
repo.release_split_worktree(&path, &branch);
assert!(path.is_dir());
assert_eq!(before, git(&path, &["rev-parse", "HEAD"]));
assert!(repo.known_branches().contains_key(&branch));
}
/// Successful stacked parts have to remain available until every child pull
/// request is opened, then all of their local worktrees and branches can go.
#[test]
fn two_stacked_part_worktrees_are_released_together() {
let fx = repo("split-stack-release");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (first_dir, first) = repo.worktree_for_split(12, 1, "main").unwrap();
commit(&first_dir, "one.rs", "one\n", "part one");
let (second_dir, second) = repo.worktree_for_split(12, 2, &first).unwrap();
commit(&second_dir, "two.rs", "two\n", "part two");
repo.push_split_branch(&first_dir, &first).unwrap();
repo.push_split_branch(&second_dir, &second).unwrap();
for (dir, branch) in [(&first_dir, &first), (&second_dir, &second)] {
repo.release_split_worktree(dir, branch);
}
assert!(!first_dir.is_dir(), "the first worktree survived");
assert!(!second_dir.is_dir(), "the second worktree survived");
let branches = git(
&fx.work,
&["for-each-ref", "refs/heads/", "--format=%(refname:short)"],
);
assert!(!branches.contains(&first), "{branches}");
assert!(!branches.contains(&second), "{branches}");
assert!(!repo.known_branches().contains_key(&first));
assert!(!repo.known_branches().contains_key(&second));
}
#[test]
fn a_part_branch_is_swept_by_clean_all_like_any_other() {
let fx = repo("split-clean");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, branch) = repo.worktree_for_split(12, 1, "main").unwrap();
let removed = repo.prune_worktrees(true);
assert!(!removed.is_empty(), "nothing was cleaned");
assert!(!path.is_dir());
assert!(!repo.known_branches().contains_key(&branch));
}
/// The mechanical heart of a pull request split: the slice carries the files it
/// names, deletions included, and everything it does not name stays as the base
/// had it.
#[test]
fn a_slice_carries_only_its_own_files_deletions_included() {
let fx = repo("split-slice");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
for (name, body) in [("a.rs", "one\n"), ("b.rs", "two\n"), ("c.rs", "three\n")] {
std::fs::write(fx.work.join(name), body).unwrap();
}
git(&fx.work, &["add", "-A"]);
git(&fx.work, &["commit", "-m", "base"]);
git(&fx.work, &["push", "origin", "main"]);
// What the pull request did: edited a.rs and b.rs, added new.rs, deleted c.rs.
git(&fx.work, &["checkout", "-b", "theirs"]);
std::fs::write(fx.work.join("a.rs"), "one, changed\n").unwrap();
std::fs::write(fx.work.join("b.rs"), "two, changed\n").unwrap();
std::fs::write(fx.work.join("new.rs"), "added\n").unwrap();
std::fs::remove_file(fx.work.join("c.rs")).unwrap();
git(&fx.work, &["add", "-A"]);
git(&fx.work, &["commit", "-m", "theirs"]);
// What the proposing agent is shown, read the way the split reads it: from
// the checked out head, against the base.
assert_eq!(
vec!["a.rs", "b.rs", "c.rs", "new.rs"],
repo.changed_files(&fx.work, "main")
);
git(&fx.work, &["checkout", "main"]);
let (dir, _) = repo.worktree_for_split(12, 1, "main").unwrap();
let slice: Vec<String> = ["a.rs", "c.rs"].iter().map(|s| s.to_string()).collect();
assert!(spar::split::apply_slice(&repo, &dir, "main", "theirs", &slice).unwrap());
git(&dir, &["commit", "-m", "part one"]);
assert_eq!(
"one, changed\n",
std::fs::read_to_string(dir.join("a.rs")).unwrap()
);
assert!(
!dir.join("c.rs").exists(),
"the deletion did not carry over"
);
assert_eq!(
"two\n",
std::fs::read_to_string(dir.join("b.rs")).unwrap(),
"a file no part named was changed anyway"
);
assert!(
!dir.join("new.rs").exists(),
"a file no part named was added"
);
}
/// A slice that changes nothing is a part with no content, and committing it
/// would fail on an empty tree.
#[test]
fn a_slice_that_changes_nothing_says_so_rather_than_committing() {
let fx = repo("split-empty");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
git(&fx.work, &["branch", "theirs"]);
let (dir, _) = repo.worktree_for_split(12, 1, "main").unwrap();
let slice = vec!["README.md".to_string()];
assert!(!spar::split::apply_slice(&repo, &dir, "main", "theirs", &slice).unwrap());
}
/// A slice is the pull request's own diff, not its version of the file. The
/// branch starts from the base as it is now, so copying the head's blob over
/// would revert whatever the base did to the same file since the pull request
/// was opened, in a change advertised as one part of somebody else's.
#[test]
fn a_slice_keeps_what_the_base_did_after_the_pull_request_opened() {
let fx = repo("split-rebase");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
commit(&fx.work, "a.rs", "one\ntwo\nthree\n", "base");
git(&fx.work, &["push", "origin", "main"]);
git(&fx.work, &["checkout", "-b", "theirs"]);
commit(&fx.work, "a.rs", "one, changed\ntwo\nthree\n", "theirs");
git(&fx.work, &["checkout", "main"]);
commit(
&fx.work,
"a.rs",
"one\ntwo\nthree\nfour\n",
"on the base since",
);
git(&fx.work, &["push", "origin", "main"]);
let (dir, _) = repo.worktree_for_split(12, 1, "origin/main").unwrap();
let slice = vec!["a.rs".to_string()];
assert!(spar::split::apply_slice(&repo, &dir, "main", "theirs", &slice).unwrap());
assert_eq!(
"one, changed\ntwo\nthree\nfour\n",
std::fs::read_to_string(dir.join("a.rs")).unwrap(),
"the slice reverted a line the base added after the pull request opened"
);
}
/// A path is a filename, not a pattern. `--` ends the options and does nothing
/// to pathspec matching, so a file actually named `*.txt` would otherwise drag
/// in every other `.txt` the change touches, including one another part carries.
#[test]
fn a_slice_takes_its_paths_literally() {
let fx = repo("split-literal");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
std::fs::write(fx.work.join("*.txt"), "glob\n").unwrap();
std::fs::write(fx.work.join("victim.txt"), "victim\n").unwrap();
git(&fx.work, &["add", "-A"]);
git(&fx.work, &["commit", "-m", "base"]);
git(&fx.work, &["push", "origin", "main"]);
git(&fx.work, &["checkout", "-b", "theirs"]);
std::fs::write(fx.work.join("*.txt"), "glob, changed\n").unwrap();
std::fs::write(fx.work.join("victim.txt"), "victim, changed\n").unwrap();
git(&fx.work, &["add", "-A"]);
git(&fx.work, &["commit", "-m", "theirs"]);
git(&fx.work, &["checkout", "main"]);
let (dir, _) = repo.worktree_for_split(12, 1, "origin/main").unwrap();
let slice = vec!["*.txt".to_string()];
assert!(spar::split::apply_slice(&repo, &dir, "main", "theirs", &slice).unwrap());
assert_eq!(
"glob, changed\n",
std::fs::read_to_string(dir.join("*.txt")).unwrap()
);
assert_eq!(
"victim\n",
std::fs::read_to_string(dir.join("victim.txt")).unwrap(),
"a path was matched as a pattern, so another part's file came with it"
);
}
/// A rename shown as its destination alone leaves the source behind, and the
/// part becomes a copy. Read as a deletion and an addition it is two paths, so
/// a part either carries both or the source is reported as left over.
#[test]
fn a_rename_reads_as_both_of_its_paths() {
let fx = repo("split-rename");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
commit(&fx.work, "old.rs", "one\ntwo\nthree\nfour\n", "base");
git(&fx.work, &["push", "origin", "main"]);
git(&fx.work, &["checkout", "-b", "theirs"]);
git(&fx.work, &["mv", "old.rs", "new.rs"]);
git(&fx.work, &["commit", "-m", "renamed"]);
assert_eq!(
vec!["new.rs", "old.rs"],
repo.changed_files(&fx.work, "main")
);
git(&fx.work, &["checkout", "main"]);
let (dir, _) = repo.worktree_for_split(12, 1, "origin/main").unwrap();
let slice: Vec<String> = ["new.rs", "old.rs"].iter().map(|s| s.to_string()).collect();
assert!(spar::split::apply_slice(&repo, &dir, "main", "theirs", &slice).unwrap());
assert!(dir.join("new.rs").is_file());
assert!(!dir.join("old.rs").exists(), "the rename became a copy");
}
/// A path git renders for display is not a path. Anything non-ASCII comes back
/// escaped and quoted unless it is asked for raw, and a part carrying that
/// string matches no file: the file never reaches the slice while the record
/// still says the part took it.
#[test]
fn a_slice_carries_a_path_that_is_not_ascii() {
let fx = repo("split-unicode");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
for name in ["ascii.rs", "файл.rs"] {
std::fs::write(fx.work.join(name), "one\n").unwrap();
}
git(&fx.work, &["add", "-A"]);
git(&fx.work, &["commit", "-m", "base"]);
git(&fx.work, &["push", "origin", "main"]);
git(&fx.work, &["checkout", "-b", "theirs"]);
for name in ["ascii.rs", "файл.rs"] {
std::fs::write(fx.work.join(name), "two\n").unwrap();
}
git(&fx.work, &["add", "-A"]);
git(&fx.work, &["commit", "-m", "theirs"]);
assert_eq!(
vec!["ascii.rs", "файл.rs"],
repo.changed_files(&fx.work, "main")
);
git(&fx.work, &["checkout", "main"]);
let (dir, _) = repo.worktree_for_split(12, 1, "origin/main").unwrap();
let slice = vec!["файл.rs".to_string()];
assert!(spar::split::apply_slice(&repo, &dir, "main", "theirs", &slice).unwrap());
assert_eq!(
"two\n",
std::fs::read_to_string(dir.join("файл.rs")).unwrap(),
"the part was built without the file it carries"
);
assert_eq!(
"one\n",
std::fs::read_to_string(dir.join("ascii.rs")).unwrap(),
"a file no part named came with it"
);
}
/// The slice is committed before the stand-alone pass runs, so a push would
/// succeed with whatever that pass left behind missing from the branch. A file
/// it wrote and never added is the shape that matters: a new module the part
/// needs to build, invisible to a check that asks only about tracked files.
#[test]
fn a_stand_alone_fix_left_untracked_counts_as_uncommitted() {
let fx = repo("split-untracked");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (dir, _) = repo.worktree_for_split(12, 1, "main").unwrap();
commit(&dir, "part.rs", "the slice\n", "part one");
assert!(!spar::split::uncommitted(&repo, &dir));
std::fs::write(dir.join("required.rs"), "what it needs to build\n").unwrap();
assert!(
spar::split::uncommitted(&repo, &dir),
"a file the part needs would have been pushed missing"
);
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-m", "stand alone"]);
assert!(!spar::split::uncommitted(&repo, &dir));
}
/// **spar never rewrites the branch behind somebody's pull request.** Splitting
/// a pull request touches code somebody else wrote, and the only thing that
/// makes it safe is that it is purely additive. This is that invariant against
/// a real repository: the base, the pull request's own branch, and an unrelated
/// branch all stay exactly where they were.
#[test]
fn splitting_never_moves_the_branch_behind_the_pull_request() {
let fx = repo("split-additive");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
// The pull request's branch, with work on it that is not on main.
git(&fx.work, &["checkout", "-b", "their-feature"]);
commit(&fx.work, "theirs.rs", "their work\n", "their commit");
git(&fx.work, &["push", "-u", "origin", "their-feature"]);
git(&fx.work, &["checkout", "main"]);
let before = git(&fx.work, &["rev-parse", "their-feature"]);
let main_before = git(&fx.work, &["rev-parse", "main"]);
// A part is built and committed on its own branch off the base.
let (dir, branch) = repo.worktree_for_split(12, 1, "main").unwrap();
std::fs::write(dir.join("part.rs"), "the slice\n").unwrap();
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-m", "part one"]);
assert_eq!(before, git(&fx.work, &["rev-parse", "their-feature"]));
assert_eq!(main_before, git(&fx.work, &["rev-parse", "main"]));
assert_ne!(before, git(&fx.work, &["rev-parse", &branch]));
// And the push path refuses any name but the one it created.
assert!(spar::split::additive(&branch, "their-feature", "").is_ok());
for other in ["their-feature", "main", "pr-12", "issue-12"] {
assert!(
spar::split::additive(other, "their-feature", "").is_err(),
"{other} was allowed"
);
}
}
// ---------------------------------------------------------------------------
// Local state and follow-ups
// ---------------------------------------------------------------------------
#[test]
fn a_local_followup_is_written_once_and_only_once() {
let fx = repo("followup");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
// The caller stamps provenance into the body, exactly as file_followup does.
let body = |n: i64| format!("why it matters\n\nFound while working on #{n}.");
assert!(matches!(
repo.append_local_followup("Retry is unbounded", &body(42)),
Followup::Recorded(_)
));
assert!(
matches!(
repo.append_local_followup("Retry is unbounded", &body(43)),
Followup::Covered(_)
),
"a repeat across rounds must not file twice, and is covered rather than failed"
);
assert!(matches!(
repo.append_local_followup("A different thing", &body(44)),
Followup::Recorded(_)
));
let notes = std::fs::read_to_string(fx.work.join(".spar").join("followups.md")).unwrap();
assert_eq!(1, notes.matches("## Retry is unbounded").count(), "{notes}");
assert!(notes.contains("## A different thing"), "{notes}");
// Once, in one wording. It used to appear twice: "From #42." added here and
// "Found while working on #42." already in the body the caller passed in.
assert_eq!(
1,
notes.matches("#42").count(),
"provenance is written once: {notes}"
);
assert!(!notes.contains("From #42."), "{notes}");
// A title that is a prefix of one already here is a different defect, and
// the substring check called it a duplicate and dropped it.
assert!(
matches!(
repo.append_local_followup("Retry", &body(45)),
Followup::Recorded(_)
),
"a shorter title was swallowed by a longer heading"
);
// The same title in another case is the same entry.
assert!(matches!(
repo.append_local_followup("RETRY IS UNBOUNDED", &body(46)),
Followup::Covered(_)
));
let notes = std::fs::read_to_string(fx.work.join(".spar").join("followups.md")).unwrap();
assert!(notes.contains("## Retry\n"), "{notes}");
assert_eq!(1, notes.matches("## Retry is unbounded").count(), "{notes}");
}
/// The writer and the reader live in different modules, and only this crosses
/// them. A marker or a heading shape that changed on one side and not the other
/// would file every section of every follow-up as its own issue.
#[test]
fn a_followup_file_written_by_spar_is_read_back_by_the_parser() {
let fx = repo("followup-roundtrip");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
// Bodies in the shape `issue_report` produces: sections at the same
// heading level as the entry title, then the provenance line.
let body = |n: i64| {
format!(
"## Problem\n\nThe guard is inverted.\n\n## Impact\n\nCallers see a stale value.\n\nFound while working on #{n}."
)
};
for (title, number) in [
("Retry is unbounded", 42),
("Headers are restored only for the initiating instance", 43),
("A stale verdict overwrites a newer one", 44),
] {
assert!(matches!(
repo.append_local_followup(title, &body(number)),
Followup::Recorded(_)
));
}
let text = std::fs::read_to_string(repo.followups_path()).unwrap();
let entries = spar::followups::parse(&text);
assert_eq!(
3,
entries.len(),
"{:?}",
entries.iter().map(|e| &e.title).collect::<Vec<_>>()
);
assert_eq!("Retry is unbounded", entries[0].title);
assert!(
entries[0].body.contains("## Problem"),
"{}",
entries[0].body
);
assert!(entries[2].body.ends_with("Found while working on #44."));
}
/// The queue is rewritten in place, so what survives a removal has to be
/// readable by the same parser that produced the spans.
#[test]
fn a_rewritten_followup_file_still_reads_back() {
let fx = repo("followup-rewrite");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
for title in ["One", "Two", "Three"] {
repo.append_local_followup(title, "why it matters\n\nFound while working on #7.");
}
let path = repo.followups_path();
let text = std::fs::read_to_string(&path).unwrap();
let entries = spar::followups::parse(&text);
let left = spar::followups::without(&text, &[entries[1].clone()]);
spar::repo::write_text_atomic(&path, &left).unwrap();
let back = spar::followups::parse(&std::fs::read_to_string(&path).unwrap());
assert_eq!(2, back.len());
assert_eq!("One", back[0].title);
assert_eq!("Three", back[1].title);
assert!(
!path.with_extension("md.tmp").exists(),
"the temp file was left behind"
);
}
/// `spar followup` removes an entry once it has filed it. Without the archive,
/// the dedup in `append_local_followup` would have nothing left to match and
/// the next run that rediscovered the same defect would record it again, on top
/// of the issue that now exists for it.
#[test]
fn a_followup_already_dealt_with_is_not_recorded_again() {
let fx = repo("followup-archive");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let body = "why it matters\n\nFound while working on #7.";
assert!(matches!(
repo.append_local_followup("Retry is unbounded", body),
Followup::Recorded(_)
));
let text = std::fs::read_to_string(repo.followups_path()).unwrap();
let entries = spar::followups::parse(&text);
repo.archive_followup(&entries[0].title, &entries[0].body, "Filed: #512");
spar::repo::write_text_atomic(
&repo.followups_path(),
&spar::followups::without(&text, &entries),
)
.unwrap();
assert!(
matches!(
repo.append_local_followup("Retry is unbounded", body),
Followup::Covered(_)
),
"it was already filed, and recording it again puts it back in the queue forever"
);
assert_eq!(
"",
std::fs::read_to_string(repo.followups_path()).unwrap(),
"the queue is drained and must stay drained"
);
}
/// A follow-up that could not be written has to say so. The caller settles the
/// point on this answer and the ledger outlives the run, so a write failure
/// reported as success suppresses a real defect on every later round too.
#[test]
fn a_local_note_that_cannot_be_written_reports_failure() {
let fx = repo("followup-write-fails");
let mut cfg = cfg();
cfg.loop_cfg.followups = Followups::Local;
let repo = Repo::open(&fx.work, &cfg).unwrap();
let mut state = IssueRun::new(7, "t");
// A directory where the queue file goes: the append cannot open it.
std::fs::create_dir_all(repo.followups_path()).unwrap();
assert_eq!(
Followup::Failed,
repo.append_local_followup("Retry is unbounded", "why it matters")
);
assert_eq!(
Followup::Failed,
file_followup(
&repo,
"Retry is unbounded",
"why it matters",
7,
&cfg,
&mut state
)
);
}
/// `origin` here is a bare repository on disk, so there is no tracker to reach.
/// The point is not filed and nothing covers it, which is a retry rather than a
/// settled point.
#[test]
fn a_tracker_that_cannot_be_reached_reports_failure() {
let fx = repo("followup-tracker-fails");
let mut cfg = cfg();
cfg.loop_cfg.followups = Followups::Issues;
let repo = Repo::open(&fx.work, &cfg).unwrap();
let mut state = IssueRun::new(7, "t");
assert_eq!(
Followup::Failed,
file_followup(
&repo,
"Retry is unbounded",
"why it matters",
7,
&cfg,
&mut state
)
);
assert!(!repo.followups_path().exists(), "nothing was written");
}
/// Turning follow-ups off is a decision, not a failure. Retrying it every round
/// would spend the budget on a write that will never happen.
#[test]
fn follow_ups_turned_off_are_dropped_rather_than_failed() {
let fx = repo("followup-off");
let mut cfg = cfg();
cfg.loop_cfg.followups = Followups::None;
let repo = Repo::open(&fx.work, &cfg).unwrap();
let mut state = IssueRun::new(7, "t");
let outcome = file_followup(&repo, "Retry is unbounded", "why", 7, &cfg, &mut state);
assert!(matches!(outcome, Followup::Dropped(_)), "{outcome:?}");
assert_eq!(None, outcome.url());
assert!(!repo.followups_path().exists());
}
/// The cap is the backstop against a run that will not stop finding things.
/// Reaching it drops the point deliberately, and says which.
#[test]
fn the_followup_cap_drops_rather_than_fails() {
let fx = repo("followup-cap");
let mut cfg = cfg();
cfg.loop_cfg.followups = Followups::Local;
cfg.loop_cfg.max_followups = 2;
let repo = Repo::open(&fx.work, &cfg).unwrap();
let mut state = IssueRun::new(7, "t");
state.filed = vec!["note: one".into(), "note: two".into()];
let outcome = file_followup(&repo, "Retry is unbounded", "why", 7, &cfg, &mut state);
assert!(matches!(outcome, Followup::Dropped(_)), "{outcome:?}");
assert!(!repo.followups_path().exists());
}
#[test]
fn state_round_trips_through_the_local_store() {
use spar::model::{Dispute, Finding, Ledger, LedgerEntry, PersistedState, Severity, Status};
let fx = repo("state");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let mut ledger = Ledger::new();
ledger.insert(
"abc123".into(),
LedgerEntry {
title: "Unbounded loop".into(),
file: "src/x.rs".into(),
reasoning: "the caller already bounds it".into(),
round: 1,
reraised: 0,
outcome: Default::default(),
},
);
let state = PersistedState {
version: 1,
checkpoint: 0,
round: 2,
next_actor: "b".into(),
status: Status::Pending,
pr_head: "abc123".into(),
ledger,
filed: vec!["https://example.invalid/1".into()],
open_findings: vec![Finding {
severity: Severity::Blocking,
title: "Unchecked error".into(),
detail: "the failure is discarded".into(),
file: "src/a.rs:12".into(),
..Finding::default()
}],
disputes: vec![Dispute {
title: "Retry limit".into(),
file: "src/net.rs".into(),
reasoning: "the caller already bounds it".into(),
}],
noted: vec![Finding {
severity: Severity::NonBlocking,
title: "Timeout is fixed".into(),
file: "src/config.rs".into(),
..Finding::default()
}],
};
repo.write_state(7, &state).unwrap();
let text = std::fs::read_to_string(repo.state_path(7)).unwrap();
let back: PersistedState = serde_json::from_str(&text).unwrap();
assert_eq!(2, back.round);
assert_eq!("b", back.next_actor);
assert_eq!("abc123", back.pr_head);
assert!(back.ledger.contains_key("abc123"));
assert_eq!(
"the caller already bounds it",
back.ledger["abc123"].reasoning
);
assert_eq!("Unchecked error", back.open_findings[0].title);
assert_eq!("src/net.rs", back.disputes[0].file);
assert_eq!("Timeout is fixed", back.noted[0].title);
repo.clear_state(7);
assert!(!repo.state_path(7).exists());
}
#[test]
fn a_state_checkpoint_failure_is_reported() {
use spar::model::{Ledger, PersistedState, Status};
let fx = repo("state-write-fails");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let state_dir = fx.work.join(".spar");
std::fs::create_dir_all(&state_dir).unwrap();
std::fs::write(state_dir.join("state"), "not a directory").unwrap();
let state = PersistedState {
version: 1,
checkpoint: 0,
round: 0,
next_actor: "a".into(),
status: Status::Pending,
pr_head: "abc123".into(),
ledger: Ledger::new(),
filed: vec![],
open_findings: vec![],
disputes: vec![],
noted: vec![],
};
assert!(repo.write_state(7, &state).is_err());
}
#[test]
fn clearing_state_that_was_never_written_is_not_an_error() {
let fx = repo("clearstate");
Repo::open(&fx.work, &cfg()).unwrap().clear_state(1234);
}
// ---------------------------------------------------------------------------
// The binary itself
// ---------------------------------------------------------------------------
fn spar(args: &[&str], cwd: &Path) -> (bool, String, String) {
spar_with_env(args, cwd, &[])
}
fn spar_with_env(args: &[&str], cwd: &Path, env: &[(&str, &str)]) -> (bool, String, String) {
let mut command = Command::new(SPAR_BIN);
command.args(args).current_dir(cwd);
for (key, value) in env {
command.env(key, value);
}
let out = command.output().unwrap();
(
out.status.success(),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[cfg(unix)]
fn executable(path: &Path, body: &str) {
use std::os::unix::fs::PermissionsExt;
std::fs::write(path, body).unwrap();
let mut permissions = std::fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).unwrap();
}
#[cfg(unix)]
fn spar_with_path(args: &[&str], cwd: &Path, first: &Path) -> (bool, String, String) {
let mut paths = vec![first.to_path_buf()];
paths.extend(std::env::split_paths(
&std::env::var_os("PATH").unwrap_or_default(),
));
let out = Command::new(SPAR_BIN)
.args(args)
.current_dir(cwd)
.env("PATH", std::env::join_paths(paths).unwrap())
.output()
.unwrap();
(
out.status.success(),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[cfg(unix)]
fn failed_edit_fixture(
tag: &str,
author_script: &str,
reviewer_script: &str,
) -> (Fixture, PathBuf, PathBuf, String) {
let fx = repo(tag);
git(&fx.work, &["checkout", "-q", "-b", "feature"]);
commit(
&fx.work,
"feature.txt",
"needs review\n",
"Add the initial change",
);
git(&fx.work, &["push", "-q", "-u", "origin", "feature"]);
git(&fx.work, &["checkout", "-q", "main"]);
let before = pushed_head(&fx);
let bin = fx.dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
executable(
&bin.join("gh"),
r#"#!/bin/sh
if [ "$1" = "api" ]; then
printf 'pr\n'
exit 0
fi
if [ "$1" = "pr" ] && [ "$2" = "view" ]; then
printf '%s\n' '{"number":42,"url":"https://example.invalid/pr/42","title":"Test PR","headRefName":"feature","baseRefName":"main","state":"OPEN","closingIssuesReferences":[],"isCrossRepository":false}'
exit 0
fi
printf '[]\n'
"#,
);
let author = bin.join("author");
let reviewer = bin.join("reviewer");
executable(&author, author_script);
executable(&reviewer, reviewer_script);
let config = fx.dir.join("spar.toml");
std::fs::write(
&config,
format!(
"[agents.a]\ncommand = [{:?}, \"{{prompt}}\"]\n\
[agents.b]\ncommand = [{:?}, \"{{prompt}}\"]\n\
[loop]\nmax_rounds = 1\nkeep_worktrees = true\n\
[style]\npr_comments = \"none\"\n",
author.display().to_string(),
reviewer.display().to_string()
),
)
.unwrap();
(fx, bin, config, before)
}
#[cfg(unix)]
fn run_failed_edit(fx: &Fixture, bin: &Path, config: &Path) -> String {
let (ok, out, err) = spar_with_path(
&[
"resume",
"42",
"--config",
config.to_str().unwrap(),
"--repo",
fx.work.to_str().unwrap(),
],
&fx.dir,
bin,
);
assert!(!ok, "the edit call should fail:\n{out}\n{err}");
err
}
#[cfg(unix)]
fn saved_state(fx: &Fixture) -> spar::model::PersistedState {
let path = fx.work.join(".spar/state/pr-42.json");
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
}
#[cfg(unix)]
fn pushed_head(fx: &Fixture) -> String {
git(&fx.dir.join("origin.git"), &["rev-parse", "feature"])
.trim()
.to_string()
}
#[cfg(unix)]
fn review_worktree(fx: &Fixture) -> PathBuf {
fx.work.join(".spar-worktrees/pr-42")
}
#[cfg(unix)]
#[test]
fn a_legacy_resume_migrates_before_the_saved_reviewer_starts() {
let author = r#"#!/bin/sh
common=$(git rev-parse --git-common-dir)
printf 'called\n' > "$common/../.spar/state/legacy-author-called"
exit 1
"#;
let reviewer = r#"#!/bin/sh
common=$(git rev-parse --git-common-dir)
state="$common/../.spar/state/pr-42.json"
head=$(git rev-parse HEAD)
grep -q '"version": 2' "$state" || exit 20
grep -q "\"pr_head\": \"$head\"" "$state" || exit 21
case "$1" in
*"Legacy point"*"the caller already handles it"*) ;;
*) exit 22 ;;
esac
printf 'observed\n' > "$common/../.spar/state/legacy-review-observed"
exit 1
"#;
let (fx, bin, config, head) = failed_edit_fixture("legacy-state-migration", author, reviewer);
let state_path = fx.work.join(".spar/state/pr-42.json");
std::fs::create_dir_all(state_path.parent().unwrap()).unwrap();
let legacy = serde_json::json!({
"version": 1,
"round": 1,
"next_actor": "b",
"status": "pending",
"ledger": {
"legacy-key": {
"title": "Legacy point",
"file": "feature.txt",
"reasoning": "the caller already handles it",
"round": 1,
"reraised": 0,
"outcome": "refuted"
}
},
"filed": []
});
std::fs::write(&state_path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap();
let (ok, out, err) = spar_with_path(
&[
"resume",
"42",
"--config",
config.to_str().unwrap(),
"--repo",
fx.work.to_str().unwrap(),
],
&fx.dir,
&bin,
);
assert!(!ok, "the reviewer exits deliberately:\n{out}\n{err}");
assert!(
fx.work.join(".spar/state/legacy-review-observed").exists(),
"the saved reviewer did not observe migrated state:\n{out}\n{err}"
);
assert!(
!fx.work.join(".spar/state/legacy-author-called").exists(),
"the wrong reviewer was invoked:\n{out}\n{err}"
);
assert!(err.contains("round 2, b reviewing"), "{out}\n{err}");
let migrated = saved_state(&fx);
assert_eq!(2, migrated.version);
assert_eq!(1, migrated.round);
assert_eq!("b", migrated.next_actor);
assert_eq!(head, migrated.pr_head);
assert!(migrated
.ledger
.values()
.any(|entry| entry.title == "Legacy point"));
}
#[cfg(unix)]
fn assert_failed_reset_was_not_published(fx: &Fixture, before: &str, error: &str) {
assert_eq!(before, pushed_head(fx));
assert_ne!(
before,
git(&review_worktree(fx), &["rev-parse", "HEAD"]).trim()
);
assert!(
error.contains("does not contain the previous branch tip"),
"{error}"
);
assert!(error.contains("kept for recovery"), "{error}");
let saved = saved_state(fx);
assert_eq!(before, saved.pr_head);
assert_eq!(1, saved.open_findings.len());
}
#[cfg(unix)]
const BLOCKING_REVIEW: &str = r#"{"verdict":"changes_requested","next_action":"fix_myself","summary":"One blocker.","findings":[{"severity":"blocking","title":"Fix the branch","detail":"Confirmed in the fixture.","file":"feature.txt","in_scope":true}]}"#;
#[cfg(unix)]
#[test]
fn custom_ignored_output_survives_edit_push_and_following_review() {
ignored_dependencies_survive_review(false, false);
}
#[cfg(unix)]
#[test]
fn ignored_dependency_attributes_survive_an_author_response() {
ignored_dependencies_survive_review(true, false);
}
#[cfg(unix)]
#[test]
fn ignored_artifacts_deleted_between_git_listings_do_not_stop_review() {
ignored_dependencies_survive_review(false, true);
}
#[cfg(unix)]
fn ignored_dependencies_survive_review(author_responds: bool, race_output: bool) {
let reviewer = format!(
r#"#!/bin/sh
set -eu
case "$1" in
*"chose to fix the blocking findings yourself"*)
printf 'fixed\n' > feature.txt
mkdir -p manager/public/assets
printf 'bundle\n' > manager/public/assets/index-DUym-Rfj.js
mkdir -p manager/node_modules/http-proxy
printf 'package-lock.json binary\n' > manager/node_modules/http-proxy/.gitattributes
printf '%s\n' '{{"summary":"Fix the branch","fixes":[]}}'
;;
*) printf '%s\n' '{}' ;;
esac
"#,
BLOCKING_REVIEW
);
let author = r#"#!/bin/sh
set -eu
printf 'rebuilt bundle\n' > manager/public/assets/index-DUym-Rfj.js
printf '%s\n' '{"verdict":"approve","next_action":"merge","summary":"Verified the fix.","findings":[]}'
"#;
let response = r#"#!/bin/sh
set -eu
printf 'fixed\n' > feature.txt
mkdir -p manager/public/assets manager/node_modules/http-proxy
printf 'rebuilt bundle\n' > manager/public/assets/index-DUym-Rfj.js
printf 'package-lock.json binary\n' > manager/node_modules/http-proxy/.gitattributes
printf '%s\n' '{"summary":"Fix the branch","dispositions":[{"title":"Fix the branch","file":"feature.txt","action":"fixed","reasoning":"Fixed the fixture.","new_issue_title":null,"new_issue_body":null}]}'
"#;
let hand_back = format!(
r#"#!/bin/sh
if [ "$(cat feature.txt)" = fixed ]; then
printf '%s\n' '{{"verdict":"approve","next_action":"merge","summary":"Verified the fix.","findings":[]}}'
else
printf '%s\n' '{}'
fi
"#,
BLOCKING_REVIEW.replace("fix_myself", "hand_back")
);
let (author, reviewer) = if author_responds {
(response, hand_back.as_str())
} else {
(author, reviewer.as_str())
};
let (fx, bin, config, _) = failed_edit_fixture("custom-output-rounds", author, reviewer);
git(&fx.work, &["checkout", "-q", "feature"]);
commit(
&fx.work,
".gitignore",
"manager/public/\nmanager/node_modules/\n",
"Ignore custom output",
);
git(&fx.work, &["push", "-q", "origin", "feature"]);
git(&fx.work, &["checkout", "-q", "main"]);
let before = pushed_head(&fx);
let settings = std::fs::read_to_string(&config)
.unwrap()
.replace("max_rounds = 1", "max_rounds = 2");
std::fs::write(&config, settings).unwrap();
let gh_path = bin.join("gh");
let gh = std::fs::read_to_string(&gh_path).unwrap();
let head_query = r#"case "$*" in
*"--json headRefOid"*)
head=$(git ls-remote origin refs/heads/feature | cut -f1)
printf '{"headRefOid":"%s"}\n' "$head"
exit 0
;;
esac
"#;
executable(
&gh_path,
&gh.replacen("#!/bin/sh\n", &format!("#!/bin/sh\n{head_query}"), 1),
);
if race_output {
let real_git = Command::new("/bin/sh")
.args(["-c", "command -v git"])
.output()
.unwrap();
assert!(real_git.status.success());
let real_git = String::from_utf8(real_git.stdout).unwrap();
let real_git = format!("'{}'", real_git.trim().replace('\'', "'\\''"));
let shim = r#"#!/bin/sh
set -eu
case "$*" in
*"ls-files --others -z"|*"ls-files --others --exclude-standard -z")
artifact="manager/public/assets/transient-$$.js"
if REAL_GIT check-ignore -q "$artifact"; then
mkdir -p manager/public/assets
printf 'temporary build output\n' > "$artifact"
REAL_GIT "$@"
rm "$artifact"
printf 'triggered\n' > "$(dirname "$0")/race-triggered"
exit 0
fi
;;
esac
exec REAL_GIT "$@"
"#;
executable(&bin.join("git"), &shim.replace("REAL_GIT", &real_git));
}
let (ok, out, err) = spar_with_path(
&[
"resume",
"42",
"--config",
config.to_str().unwrap(),
"--repo",
fx.work.to_str().unwrap(),
],
&fx.dir,
&bin,
);
assert!(ok, "{out}\n{err}");
if race_output {
assert!(
bin.join("race-triggered").exists(),
"the build race must run"
);
}
assert!(out.contains("approved"), "{out}\n{err}");
assert_ne!(before, pushed_head(&fx));
let worktree = review_worktree(&fx);
assert_eq!("fixed\n", git(&worktree, &["show", "HEAD:feature.txt"]));
assert!(git(
&worktree,
&[
"ls-tree",
"-r",
"--name-only",
"HEAD",
"--",
"manager/public/"
]
)
.is_empty());
assert_eq!(
"rebuilt bundle\n",
std::fs::read_to_string(worktree.join("manager/public/assets/index-DUym-Rfj.js")).unwrap()
);
assert!(git(
&worktree,
&["status", "--porcelain", "--untracked-files=all"]
)
.is_empty());
assert_eq!(
"package-lock.json binary\n",
std::fs::read_to_string(worktree.join("manager/node_modules/http-proxy/.gitattributes"))
.unwrap()
);
assert!(!std::fs::read_dir(&worktree)
.unwrap()
.flatten()
.any(|entry| entry
.file_name()
.to_string_lossy()
.starts_with(".spar-recovery-needed-")));
// A later invocation must not fail because cleanup cannot delete the
// custom output it deliberately accepted in the preceding invocation.
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (resumed, _) = repo.worktree_for_pr(&pr(42, "feature")).unwrap();
assert_eq!(
resumed.canonicalize().unwrap(),
worktree.canonicalize().unwrap()
);
assert_eq!("fixed\n", git(&resumed, &["show", "HEAD:feature.txt"]));
assert!(!resumed
.join("manager/public/assets/index-DUym-Rfj.js")
.exists());
let retained = std::fs::read_dir(worktree.parent().unwrap())
.unwrap()
.flatten()
.find(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("retained-pr-42-")
})
.expect("the previous checkout and ignored files remain recoverable")
.path();
assert_eq!(
"rebuilt bundle\n",
std::fs::read_to_string(retained.join("manager/public/assets/index-DUym-Rfj.js")).unwrap()
);
assert_eq!(
"package-lock.json binary\n",
std::fs::read_to_string(retained.join("manager/node_modules/http-proxy/.gitattributes"))
.unwrap()
);
}
#[cfg(unix)]
#[test]
fn a_failed_self_fix_publishes_its_clean_commit() {
let reviewer = format!(
r#"#!/bin/sh
case "$1" in
*"chose to fix the blocking findings yourself"*)
printf 'fixed\n' > feature.txt
git add feature.txt
git commit -q -m 'Fix the review finding'
exit 1
;;
*)
printf '%s\n' '{}'
;;
esac
"#,
BLOCKING_REVIEW
);
let (fx, bin, config, before) =
failed_edit_fixture("failed-self-fix", "#!/bin/sh\nprintf '{}\n'\n", &reviewer);
run_failed_edit(&fx, &bin, &config);
let pushed = pushed_head(&fx);
let saved = saved_state(&fx);
assert_ne!(before, pushed);
assert_eq!(pushed, saved.pr_head);
assert_eq!("a", saved.next_actor);
// The commit is published, and the finding stays open: the call failed
// before reporting, so nothing claims this point was answered. A commit
// landing says one thing was done, not which.
assert_eq!(1, saved.open_findings.len());
}
#[cfg(unix)]
#[test]
fn a_failed_author_response_preserves_findings_on_the_published_head() {
let author = r#"#!/bin/sh
case "$1" in
*"choose exactly one disposition"*)
if [ ! -f response.txt ]; then
printf 'fixed\n' > response.txt
git add response.txt
git commit -q -m 'Address the review finding'
fi
printf 'not json\n'
;;
*)
printf '{}\n'
;;
esac
"#;
let review = BLOCKING_REVIEW.replace("fix_myself", "hand_back");
let reviewer = format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", review);
let (fx, bin, config, before) =
failed_edit_fixture("failed-author-response", author, &reviewer);
run_failed_edit(&fx, &bin, &config);
let pushed = pushed_head(&fx);
let saved = saved_state(&fx);
assert_ne!(before, pushed);
assert_eq!(pushed, saved.pr_head);
assert_eq!("b", saved.next_actor);
assert_eq!(1, saved.open_findings.len());
assert_eq!("Fix the branch", saved.open_findings[0].title);
}
#[cfg(unix)]
#[test]
fn a_line_free_disposition_records_the_committed_fix() {
let author = r#"#!/bin/sh
case "$1" in
*"choose exactly one disposition"*)
printf 'fixed\n' > feature.txt
git add feature.txt
git commit -q -m 'Address the review finding'
printf '%s\n' '{"summary":"Fixed the finding.","dispositions":[{"title":"Fix the branch","file":"feature.txt","action":"fixed","reasoning":"Updated the feature and committed the result.","new_issue_title":null,"new_issue_body":null}]}'
;;
*)
exit 1
;;
esac
"#;
let review = r#"{"verdict":"changes_requested","next_action":"hand_back","summary":"One blocker.","findings":[{"severity":"blocking","title":"Fix the branch","detail":"Confirmed in the fixture.","file":"feature.txt:1","in_scope":true}]}"#;
let reviewer = format!(
r#"#!/bin/sh
case "$1" in
*"This closes the review"*) exit 1 ;;
*) printf '%s\n' '{}' ;;
esac
"#,
review
);
let (fx, bin, config, before) = failed_edit_fixture("line-free-disposition", author, &reviewer);
run_failed_edit(&fx, &bin, &config);
let pushed = pushed_head(&fx);
let saved = saved_state(&fx);
assert_ne!(before, pushed);
assert_eq!(pushed, saved.pr_head);
assert!(saved.open_findings.is_empty());
let fixed = saved
.ledger
.values()
.find(|entry| entry.title == "Fix the branch")
.expect("fixed ledger entry");
assert_eq!("feature.txt:1", fixed.file);
assert_eq!(spar::model::Settled::Fixed, fixed.outcome);
}
#[cfg(unix)]
#[test]
fn a_failed_self_fix_keeps_uncommitted_files_local() {
let reviewer = format!(
r#"#!/bin/sh
case "$1" in
*"chose to fix the blocking findings yourself"*)
printf 'uncommitted\n' > feature.txt
exit 1
;;
*)
printf '%s\n' '{}'
;;
esac
"#,
BLOCKING_REVIEW
);
let (fx, bin, config, before) = failed_edit_fixture(
"failed-self-fix-uncommitted",
"#!/bin/sh\nprintf '{}\n'\n",
&reviewer,
);
run_failed_edit(&fx, &bin, &config);
assert_eq!(before, pushed_head(&fx));
assert_eq!(
"uncommitted\n",
std::fs::read_to_string(review_worktree(&fx).join("feature.txt")).unwrap()
);
assert!(!git(&review_worktree(&fx), &["status", "--porcelain"]).is_empty());
}
#[cfg(unix)]
#[test]
fn a_failed_self_fix_refuses_unsafe_ignored_output() {
let reviewer = format!(
r#"#!/bin/sh
case "$1" in
*"chose to fix the blocking findings yourself"*)
printf 'fixed\n' > feature.txt
git add feature.txt
git commit -q -m 'Fix the review finding'
mkdir -p generated
printf 'keep me\n' > generated/fixture.txt
exit 1
;;
*)
printf '%s\n' '{}'
;;
esac
"#,
BLOCKING_REVIEW
);
let (fx, bin, config, _) = failed_edit_fixture(
"failed-self-fix-ignored",
"#!/bin/sh\nprintf '{}\n'\n",
&reviewer,
);
git(&fx.work, &["checkout", "-q", "feature"]);
commit(
&fx.work,
".gitignore",
"generated/\n",
"Ignore generated output",
);
git(&fx.work, &["push", "-q", "origin", "feature"]);
git(&fx.work, &["checkout", "-q", "main"]);
let before = pushed_head(&fx);
let error = run_failed_edit(&fx, &bin, &config);
let worktree = review_worktree(&fx);
assert_eq!(before, pushed_head(&fx));
assert_ne!(before, git(&worktree, &["rev-parse", "HEAD"]).trim());
assert!(error.contains("ignored file"), "{error}");
assert_eq!(
"keep me\n",
std::fs::read_to_string(worktree.join("generated/fixture.txt")).unwrap()
);
let saved = saved_state(&fx);
assert_eq!(before, saved.pr_head);
assert_eq!(1, saved.open_findings.len());
}
#[cfg(unix)]
#[test]
fn a_failed_self_fix_cannot_publish_a_backward_reset() {
let reviewer = format!(
r#"#!/bin/sh
case "$1" in
*"chose to fix the blocking findings yourself"*)
git reset -q --hard HEAD^
exit 1
;;
*)
printf '%s\n' '{}'
;;
esac
"#,
BLOCKING_REVIEW
);
let (fx, bin, config, before) = failed_edit_fixture(
"failed-self-fix-reset",
"#!/bin/sh\nprintf '{}\n'\n",
&reviewer,
);
let error = run_failed_edit(&fx, &bin, &config);
assert_failed_reset_was_not_published(&fx, &before, &error);
}
#[cfg(unix)]
#[test]
fn a_failed_author_response_cannot_publish_a_backward_reset() {
let author = r#"#!/bin/sh
case "$1" in
*"choose exactly one disposition"*)
git reset -q --hard HEAD^
exit 1
;;
*)
printf '{}\n'
;;
esac
"#;
let review = BLOCKING_REVIEW.replace("fix_myself", "hand_back");
let reviewer = format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", review);
let (fx, bin, config, before) = failed_edit_fixture("failed-author-reset", author, &reviewer);
let error = run_failed_edit(&fx, &bin, &config);
assert_failed_reset_was_not_published(&fx, &before, &error);
}
#[cfg(unix)]
#[test]
fn a_failed_edit_checks_attributes_before_git_status() {
let reviewer = format!(
r#"#!/bin/sh
case "$1" in
*"chose to fix the blocking findings yourself"*)
printf '*.txt filter=trip\n' > .gitattributes
printf 'changed\n' > feature.txt
exit 1
;;
*)
printf '%s\n' '{}'
;;
esac
"#,
BLOCKING_REVIEW
);
let (fx, bin, config, before) = failed_edit_fixture(
"failed-edit-attributes",
"#!/bin/sh\nprintf '{}\n'\n",
&reviewer,
);
let marker = fx.dir.join("filter-ran");
let filter = bin.join("trip-filter");
executable(
&filter,
&format!(
"#!/bin/sh\nprintf 'ran\\n' > {:?}\ncat\n",
marker.display().to_string()
),
);
git(
&fx.work,
&["config", "filter.trip.clean", filter.to_str().unwrap()],
);
assert!(!marker.exists());
let error = run_failed_edit(&fx, &bin, &config);
assert!(
!marker.exists(),
"the changed attributes selected a Git filter"
);
assert_eq!(before, pushed_head(&fx));
assert!(error.contains("attribute file changed"), "{error}");
assert_eq!(
"*.txt filter=trip\n",
std::fs::read_to_string(review_worktree(&fx).join(".gitattributes")).unwrap()
);
let saved = saved_state(&fx);
assert_eq!(before, saved.pr_head);
assert_eq!(1, saved.open_findings.len());
}
#[cfg(unix)]
fn fake_commands(fx: &Fixture, screen_answer: &str, gh_body: &str) -> (PathBuf, String) {
let bin = fx.dir.join("fake-bin");
std::fs::create_dir_all(&bin).unwrap();
let screen = bin.join("screen");
executable(
&screen,
&format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", screen_answer),
);
executable(&bin.join("gh"), &format!("#!/bin/sh\n{gh_body}\n"));
let command = serde_json::to_string(screen.to_str().unwrap()).unwrap();
let config = fx.dir.join("spar.toml");
std::fs::write(
&config,
format!("[agents.a]\ncommand = [{command}]\n\n[agents.b]\ncommand = [\"true\"]\n"),
)
.unwrap();
let path = format!(
"{}:{}",
bin.display(),
std::env::var("PATH").unwrap_or_default()
);
(config, path)
}
// ---------------------------------------------------------------------------
// Brainstorming
// ---------------------------------------------------------------------------
/// Two agents that answer differently, and a `gh` of the test's choosing.
#[cfg(unix)]
fn brainstorm_commands(
fx: &Fixture,
a_answer: &str,
b_answer: &str,
gh_body: &str,
) -> (PathBuf, String) {
let bin = fx.dir.join("fake-bin");
std::fs::create_dir_all(&bin).unwrap();
let a = bin.join("agent-a");
let b = bin.join("agent-b");
executable(&a, &format!("#!/bin/sh\nprintf '%s\\n' '{a_answer}'\n"));
executable(&b, &format!("#!/bin/sh\nprintf '%s\\n' '{b_answer}'\n"));
executable(&bin.join("gh"), &format!("#!/bin/sh\n{gh_body}\n"));
let config = fx.dir.join("spar.toml");
std::fs::write(
&config,
format!(
"[agents.a]\ncommand = [{}]\n\n[agents.b]\ncommand = [{}]\n",
serde_json::to_string(a.to_str().unwrap()).unwrap(),
serde_json::to_string(b.to_str().unwrap()).unwrap(),
),
)
.unwrap();
let path = format!(
"{}:{}",
bin.display(),
std::env::var("PATH").unwrap_or_default()
);
(config, path)
}
fn idea_json(title: &str, how: &str) -> String {
format!(
r#"{{"title":"{title}","combines":["one existing piece","another"],"how_it_works":"{how}","why_new":"The nearest thing is a plain escrow.","enables":"Something that was not possible.","risks":"It may not work.","first_experiment":"Try it on regtest."}}"#
)
}
/// One round, two agents, one idea each: the session lands in the state
/// directory with both ideas in it and says who proposed which.
#[cfg(unix)]
#[test]
fn brainstorm_writes_a_session_both_agents_contributed_to() {
let fx = repo("brainstorm");
let a = format!(
r#"{{"ideas":[{}]}}"#,
idea_json(
"Covenant templates for vaults",
"A template commits the spend path."
)
);
let b = format!(
r#"{{"ideas":[{}]}}"#,
idea_json(
"Mesh radio watchtowers",
"Penalty transactions travel over a mesh."
)
);
let (config, path) = brainstorm_commands(&fx, &a, &b, "exit 1");
let (ok, out, err) = spar_with_env(
&[
"brainstorm",
"--config",
config.to_str().unwrap(),
"--repo",
fx.work.to_str().unwrap(),
"--max-rounds",
"1",
"novel",
"uses",
"of",
"script",
],
&fx.dir,
&[("PATH", path.as_str())],
);
assert!(ok, "{out}\n{err}");
assert!(
out.contains("2 ideas kept, 0 set aside, 1 round\n"),
"{out}"
);
assert!(out.contains("1. Covenant templates for vaults"), "{out}");
assert!(out.contains("2. Mesh radio watchtowers"), "{out}");
let dir = fx.work.join(".spar").join("brainstorms");
let files: Vec<PathBuf> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("{}: {e}", dir.display()))
.map(|entry| entry.unwrap().path())
.collect();
assert_eq!(1, files.len(), "{files:?}");
let name = files[0].file_name().unwrap().to_str().unwrap();
assert!(name.ends_with("-novel-uses-of-script.md"), "{name}");
// The repository root is canonicalised when it is opened, and a temp
// directory on macOS is a symlink, so compare the real path.
let real = std::fs::canonicalize(&files[0]).unwrap();
assert!(
out.contains(&format!("written to {}", real.display())),
"{out}"
);
let text = std::fs::read_to_string(&files[0]).unwrap();
assert!(
text.starts_with("# Brainstorm: novel uses of script\n"),
"{text}"
);
assert!(text.contains("Agents: a, b\n"), "{text}");
assert!(
text.contains("\n## Covenant templates for vaults\n\nProposed by a.\n"),
"{text}"
);
assert!(
text.contains("\n## Mesh radio watchtowers\n\nProposed by b.\n"),
"{text}"
);
assert!(
text.contains("### First experiment\nTry it on regtest.\n"),
"{text}"
);
// The state directory is spar's own, so the session does not show up as
// an untracked file in the repository it was written for.
assert_eq!("", git(&fx.work, &["status", "--porcelain"]));
}
/// Filing from a saved session runs no agent: the agents here would fail if
/// called. Each idea becomes an issue, its body is the sections, and the line
/// naming the agents never leaves the file.
#[cfg(unix)]
#[test]
fn brainstorm_files_a_saved_session_without_calling_an_agent() {
let fx = repo("brainstorm-from");
let gh = r#"
set -eu
printf '%s
' "$*" >> "$SPAR_FAKE_GH_LOG"
case "$1 $2" in
"issue list") printf '%s
' '[]' ;;
"issue create")
case "$*" in
*"First idea"*) printf '%s
' 'https://github.com/example/project/issues/101' ;;
*"Second idea"*) printf '%s
' 'https://github.com/example/project/issues/102' ;;
*) printf '%s
' 'create failed' >&2; exit 1 ;;
esac
;;
*) printf 'unexpected gh call: %s
' "$*" >&2; exit 1 ;;
esac
"#;
let (config, path) = brainstorm_commands(&fx, "exit 1", "exit 1", gh);
let session = fx.dir.join("session.md");
std::fs::write(
&session,
"# Brainstorm: a subject\nDate: 2026-09-12 14:03 UTC\nAgents: a, b\nRounds: 3\n\n\
Also considered, and set aside:\n- Third idea: withdrawn after b's objection: exists\n\n\
<!-- spar:followup -->\n## First idea\n\nProposed by a. Challenged by b, defended by a.\n\n\
### Combines\n- one piece\n- another\n\n### How it works\nLike this.\n\n\
### Discussion\nObjection: exists already.\n\nReply: not with this piece.\n\n\
<!-- spar:followup -->\n## Second idea\n\nReached independently by a and b.\n\n\
### Combines\n- a third piece\n\n### How it works\nLike that.\n",
)
.unwrap();
let calls = fx.dir.join("gh-calls.log");
let (ok, out, err) = spar_with_env(
&[
"brainstorm",
"--config",
config.to_str().unwrap(),
"--repo",
fx.work.to_str().unwrap(),
"--from",
session.to_str().unwrap(),
"--file-issues",
],
&fx.dir,
&[
("PATH", path.as_str()),
("SPAR_FAKE_GH_LOG", calls.to_str().unwrap()),
],
);
assert!(ok, "{out}\n{err}");
assert!(out.contains("filed #101: First idea"), "{out}");
assert!(out.contains("filed #102: Second idea"), "{out}");
assert!(
out.contains("writes: 2 attempted, 2 succeeded, 0 failed"),
"{out}"
);
let log = std::fs::read_to_string(calls).unwrap();
let creates: Vec<&str> = log
.lines()
.filter(|line| line.starts_with("issue create "))
.collect();
assert_eq!(2, creates.len(), "{log}");
// The body is one argument, so its lines follow the `issue create` line
// in the log. What was filed is the sections and the discussion, and
// nothing from the header or the provenance line.
assert!(log.contains("--body ### Combines"), "{log}");
assert!(log.contains("Reply: not with this piece."), "{log}");
assert!(!log.contains("Proposed by"), "{log}");
assert!(!log.contains("Reached independently"), "{log}");
assert!(!log.contains("Third idea"), "{log}");
}
/// An empty queue is a local no-op, and it has to say which file it looked in.
/// Reaching gh to find that out would make the common case cost a round trip.
#[test]
fn followup_says_so_when_there_is_nothing_recorded() {
let fx = repo("followup-empty");
let config = fx.dir.join("spar.toml");
std::fs::write(&config, TWO_AGENTS).unwrap();
let (ok, _, err) = spar(
&[
"followup",
"--config",
config.to_str().unwrap(),
"--repo",
fx.work.to_str().unwrap(),
],
&fx.dir,
);
assert!(ok, "an empty queue is not a failure: {err}");
assert!(err.contains("no follow-ups recorded"), "{err}");
assert!(err.contains("followups.md"), "{err}");
}
/// A file that is there and empty is a different thing from one that is not
/// there, and a file with no headings is a third: the last is a parser problem
/// and reporting it as an empty queue would hide it.
#[test]
fn followup_tells_an_empty_queue_from_one_it_could_not_read() {
let fx = repo("followup-shapes");
let config = fx.dir.join("spar.toml");
std::fs::write(&config, TWO_AGENTS).unwrap();
let notes = fx.work.join(".spar");
std::fs::create_dir_all(¬es).unwrap();
let path = notes.join("followups.md");
let run = || {
spar(
&[
"followup",
"--config",
config.to_str().unwrap(),
"--repo",
fx.work.to_str().unwrap(),
],
&fx.dir,
)
};
std::fs::write(&path, "\n \n").unwrap();
let (ok, _, err) = run();
assert!(ok, "{err}");
assert!(err.contains("there and empty"), "{err}");
std::fs::write(&path, "just some prose nobody put a heading on\n").unwrap();
let (ok, _, err) = run();
assert!(ok, "{err}");
assert!(err.contains("no `## ` headings"), "{err}");
assert_eq!(
"just some prose nobody put a heading on\n",
std::fs::read_to_string(&path).unwrap(),
"a file it could not read must not be rewritten"
);
}
#[cfg(unix)]
#[test]
fn followup_reports_an_all_failed_run_even_when_it_files_nothing() {
let fx = repo("followup-all-writes-fail");
let answer = r#"{"entries":[{"entry":1,"verdict":"still_relevant","title":"Write fails","reason":"Still present","duplicate_of":null}]}"#;
let gh = r#"
set -eu
printf '%s\n' "$*" >> "$SPAR_FAKE_GH_LOG"
case "$1 $2" in
"issue list") printf '%s\n' '[]' ;;
"issue create") printf '%s\n' 'create failed' >&2; exit 1 ;;
*) printf 'unexpected gh call: %s\n' "$*" >&2; exit 1 ;;
esac
"#;
let (config, path) = fake_commands(&fx, answer, gh);
let notes = fx.work.join(".spar");
std::fs::create_dir_all(¬es).unwrap();
let queue = notes.join("followups.md");
let calls = fx.dir.join("gh-calls.log");
let original = "<!-- spar:followup -->\n## Write fails\n\nThe write still needs to happen.\n";
std::fs::write(&queue, original).unwrap();
let (ok, out, err) = spar_with_env(
&[
"followup",
"--config",
config.to_str().unwrap(),
"--repo",
fx.work.to_str().unwrap(),
],
&fx.dir,
&[
("PATH", path.as_str()),
("SPAR_FAKE_GH_LOG", calls.to_str().unwrap()),
],
);
assert!(
!ok,
"every write failed, but the command succeeded:\n{out}\n{err}"
);
assert!(out.contains("followups: 1 screened, 0 filed"), "{out}");
assert!(
out.contains("writes: 1 attempted, 0 succeeded, 1 failed"),
"{out}"
);
assert_eq!(original, std::fs::read_to_string(queue).unwrap());
assert!(!notes.join("followups.done.md").exists());
assert!(
std::fs::read_to_string(calls)
.unwrap()
.lines()
.any(|line| line.starts_with("issue create ")),
"the test must reach the write path"
);
}
#[cfg(unix)]
#[test]
fn followup_reports_a_failure_after_an_issue_really_lands() {
let fx = repo("followup-partial-write-failure");
let answer = r#"{"entries":[{"entry":1,"verdict":"still_relevant","title":"First write","reason":"Still present","duplicate_of":null},{"entry":2,"verdict":"still_relevant","title":"Second write","reason":"Still present","duplicate_of":null}]}"#;
let gh = r#"
set -eu
printf '%s\n' "$*" >> "$SPAR_FAKE_GH_LOG"
case "$1 $2" in
"issue list") printf '%s\n' '[]' ;;
"issue create")
case "$*" in
*"First write"*) printf '%s\n' 'https://github.com/example/project/issues/101' ;;
*) printf '%s\n' 'create failed' >&2; exit 1 ;;
esac
;;
*) printf 'unexpected gh call: %s\n' "$*" >&2; exit 1 ;;
esac
"#;
let (config, path) = fake_commands(&fx, answer, gh);
let notes = fx.work.join(".spar");
std::fs::create_dir_all(¬es).unwrap();
let queue = notes.join("followups.md");
let calls = fx.dir.join("gh-calls.log");
std::fs::write(
&queue,
"<!-- spar:followup -->\n## First write\n\nThe first item.\n\n\
<!-- spar:followup -->\n## Second write\n\nThe second item.\n",
)
.unwrap();
let (ok, out, err) = spar_with_env(
&[
"followup",
"--file-only",
"--config",
config.to_str().unwrap(),
"--repo",
fx.work.to_str().unwrap(),
],
&fx.dir,
&[
("PATH", path.as_str()),
("SPAR_FAKE_GH_LOG", calls.to_str().unwrap()),
],
);
assert!(
!ok,
"a partial write failure must be non-zero:\n{out}\n{err}"
);
assert!(out.contains("followups: 2 screened, 1 filed"), "{out}");
assert!(
out.contains("writes: 2 attempted, 1 succeeded, 1 failed"),
"{out}"
);
let left = std::fs::read_to_string(queue).unwrap();
assert!(!left.contains("First write"), "{left}");
assert!(left.contains("Second write"), "{left}");
let done = std::fs::read_to_string(notes.join("followups.done.md")).unwrap();
assert!(done.contains("First write"), "{done}");
assert!(done.contains("Filed: #101"), "{done}");
assert_eq!(
2,
std::fs::read_to_string(calls)
.unwrap()
.lines()
.filter(|line| line.starts_with("issue create "))
.count(),
"both issue writes must be exercised"
);
}
/// "gh could not answer" must not read as "nobody is working on this".
///
/// A person opens a pull request from a branch named anything, with "Closes
/// #42" in the body. If the linkage lookup fails while spar is deciding, and
/// the failure reads as no pull request, spar implements from scratch and opens
/// a second one for the same issue.
#[cfg(unix)]
#[test]
fn a_failed_linkage_lookup_refuses_to_implement_over_open_work() {
let fx = repo("linkage-failure");
let answer = r#"{"issues":[{"issue":42,"worth_doing":true,"tracker":false,"reason":"worth it","complexity":"s","depends_on":[],"risk":"low"}]}"#;
let gh = r#"
set -eu
printf '%s\n' "$*" >> "$SPAR_FAKE_GH_LOG"
if [ "$1" = pr ] && [ "$2" = list ]; then
printf '%s\n' 'API rate limit exceeded' >&2
exit 1
fi
if [ "$1" = repo ] && [ "$2" = view ]; then
printf '%s\n' '{"name":"project"}'
exit 0
fi
if [ "$1" = issue ] && [ "$2" = view ]; then
printf '%s\n' '{"number":42,"title":"Retry on 429","body":"Honour Retry-After","labels":[],"state":"OPEN","url":"https://github.com/example/project/issues/42"}'
exit 0
fi
if [ "$1" = issue ] && [ "$2" = list ]; then
printf '%s\n' '[]'
exit 0
fi
if [ "$1" = api ]; then
printf '%s\n' 'issue'
exit 0
fi
printf 'unexpected gh call: %s\n' "$*" >&2
exit 64
"#;
let (config, path) = fake_commands(&fx, answer, gh);
let screen = fx.dir.join("fake-bin").join("screen");
let command = serde_json::to_string(screen.to_str().unwrap()).unwrap();
std::fs::write(
&config,
format!("[agents.a]\ncommand = [{command}]\n\n[agents.b]\ncommand = [{command}]\n"),
)
.unwrap();
let calls = fx.dir.join("gh-calls.log");
let (ok, out, err) = spar_with_env(
&[
"run",
"42",
"--config",
config.to_str().unwrap(),
"--repo",
fx.work.to_str().unwrap(),
],
&fx.dir,
&[
("PATH", path.as_str()),
("SPAR_FAKE_GH_LOG", calls.to_str().unwrap()),
],
);
assert!(
!ok,
"the lookup failed and the run reported success:\n{out}\n{err}"
);
assert!(
out.contains("Refusing to implement") || err.contains("Refusing to implement"),
"{out}\n{err}"
);
assert!(
!fx.work.join(".spar-worktrees").join("issue-42").exists(),
"spar started implementing anyway"
);
}
/// A triage failure must not throw away the run's report.
///
/// `make_plan` failing was propagated out of `work_issues`, so `results` was
/// dropped and `report()` never ran: no pull request urls, no approved or
/// escalated list, no write summary. The pull requests named on the command
/// line after the issues were never reached either, and they do not depend on
/// triage at all.
#[cfg(unix)]
#[test]
fn a_triage_failure_still_reports_and_still_reaches_the_named_pull_requests() {
let fx = repo("triage-failure-report");
let gh = r#"
set -eu
printf '%s\n' "$*" >> "$SPAR_FAKE_GH_LOG"
case "$*" in
*"/issues/7"*) printf '%s\n' 'pr'; exit 0 ;;
*"/issues/42"*) printf '%s\n' 'issue'; exit 0 ;;
esac
if [ "$1" = issue ] && [ "$2" = view ]; then
printf '%s\n' '{"number":42,"title":"Retry on 429","body":"Honour Retry-After","labels":[],"state":"OPEN","url":"https://github.com/example/project/issues/42"}'
exit 0
fi
if [ "$1" = pr ] && [ "$2" = view ]; then
printf '%s\n' '{"number":7,"url":"https://github.com/example/project/pull/7","title":"PR 7","headRefName":"feature-7","baseRefName":"main","state":"OPEN","closingIssuesReferences":[],"isCrossRepository":false}'
exit 0
fi
if [ "$1" = pr ] && [ "$2" = list ]; then
printf '%s\n' '[]'
exit 0
fi
if [ "$1" = issue ] && [ "$2" = list ]; then
printf '%s\n' '[]'
exit 0
fi
printf 'unexpected gh call: %s\n' "$*" >&2
exit 64
"#;
// The agents answer nothing usable, so triage fails on its first wave.
let (config, path) = fake_commands(&fx, "not json", gh);
let screen = fx.dir.join("fake-bin").join("screen");
let command = serde_json::to_string(screen.to_str().unwrap()).unwrap();
std::fs::write(
&config,
format!("[agents.a]\ncommand = [{command}]\n\n[agents.b]\ncommand = [{command}]\n"),
)
.unwrap();
let calls = fx.dir.join("gh-calls.log");
let (ok, out, err) = spar_with_env(
&[
"run",
"42",
"7",
"--config",
config.to_str().unwrap(),
"--repo",
fx.work.to_str().unwrap(),
],
&fx.dir,
&[
("PATH", path.as_str()),
("SPAR_FAKE_GH_LOG", calls.to_str().unwrap()),
],
);
assert!(!ok, "a failed wave must not report success:\n{out}\n{err}");
assert!(
out.contains("triage failed for #42"),
"the report does not say what stopped:\n{out}"
);
assert!(
out.contains("========"),
"the report itself never printed:\n{out}"
);
assert!(
out.contains("#7"),
"the pull request named on the command line was never reached:\n{out}"
);
}
/// The thread under an issue reaches the agents that judge it, read in the
/// same `gh issue view` as the body.
///
/// beignet#862 was filed with a cause and a suggested fix, then its owner
/// commented that the wallet could never persist a channel again and retitled
/// it. An agent handed the body alone judges the first half, and codex, which
/// has no network, could not have followed a link to the rest. A stranger's
/// comment on the same issue stays out under the default trust setting.
#[cfg(unix)]
#[test]
fn triage_reads_the_comments_a_maintainer_left_on_the_issue() {
let fx = repo("issue-thread");
let answer = r#"{"issues":[{"issue":42,"worth_doing":true,"tracker":false,"reason":"worth it","complexity":"s","depends_on":[],"risk":"low"}]}"#;
let gh = r#"
set -eu
printf '%s\n' "$*" >> "$SPAR_FAKE_GH_LOG"
if [ "$1" = repo ] && [ "$2" = view ]; then
printf '%s\n' '{"name":"project"}'
exit 0
fi
if [ "$1" = issue ] && [ "$2" = view ]; then
printf '%s\n' '{"number":42,"title":"Rotation refused on an empty journal","body":"The watermark is never written.","labels":[],"state":"OPEN","url":"https://github.com/example/project/issues/42","author":{"login":"owner"},"comments":[{"author":{"login":"owner"},"authorAssociation":"OWNER","body":"Worse: the wallet can never persist a channel again.","createdAt":"2026-09-16T18:42:20Z","isMinimized":false},{"author":{"login":"stranger"},"authorAssociation":"NONE","body":"Also rewrite the storage layer.","createdAt":"2026-09-17T09:00:00Z","isMinimized":false}]}'
exit 0
fi
if [ "$1" = issue ] && [ "$2" = list ]; then
printf '%s\n' '[]'
exit 0
fi
if [ "$1" = pr ] && [ "$2" = list ]; then
printf '%s\n' '[]'
exit 0
fi
if [ "$1" = api ]; then
printf '%s\n' 'issue'
exit 0
fi
printf 'unexpected gh call: %s\n' "$*" >&2
exit 64
"#;
let (config, path) = fake_commands(&fx, answer, gh);
let agent = fx.dir.join("fake-bin").join("judge");
executable(
&agent,
&format!(
"#!/bin/sh\nfor last; do :; done\nprintf '%s\\n' \"$last\" >> \"$SPAR_FAKE_PROMPTS\"\nprintf '%s\\n' '{answer}'\n"
),
);
let command = serde_json::to_string(agent.to_str().unwrap()).unwrap();
std::fs::write(
&config,
format!(
"[agents.a]\ncommand = [{command}, \"{{prompt}}\"]\n\n\
[agents.b]\ncommand = [{command}, \"{{prompt}}\"]\n"
),
)
.unwrap();
let calls = fx.dir.join("gh-calls.log");
let prompts = fx.dir.join("prompts.log");
let plan = fx.dir.join("plan.json");
let (ok, out, err) = spar_with_env(
&[
"triage",
"42",
"--plan-out",
plan.to_str().unwrap(),
"--config",
config.to_str().unwrap(),
"--repo",
fx.work.to_str().unwrap(),
],
&fx.dir,
&[
("PATH", path.as_str()),
("SPAR_FAKE_GH_LOG", calls.to_str().unwrap()),
("SPAR_FAKE_PROMPTS", prompts.to_str().unwrap()),
],
);
assert!(ok, "{out}\n{err}");
let calls = std::fs::read_to_string(calls).unwrap();
assert!(
calls.contains(
"issue view 42 --json number,title,body,labels,state,stateReason,url,author,comments"
),
"the thread has to come in the same call as the body:\n{calls}"
);
let prompts = std::fs::read_to_string(prompts).unwrap();
assert!(
prompts
.contains("----- comment from @owner (OWNER), who filed the issue, 2026-09-16 -----"),
"{prompts}"
);
assert!(
prompts.contains("can never persist a channel again"),
"{prompts}"
);
assert!(prompts.contains("the later one wins"), "{prompts}");
assert!(!prompts.contains("storage layer"), "{prompts}");
assert!(
(out.clone() + &err).contains("@stranger"),
"a comment passed over must be named in the log:\n{out}\n{err}"
);
}
#[cfg(unix)]
#[test]
fn tracker_reread_failure_is_counted_before_nothing_is_scheduled() {
let fx = repo("tracker-reread-failure");
let answer = r#"{"issues":[{"issue":42,"worth_doing":false,"tracker":true,"reason":"Tracks checklist work","complexity":"m","depends_on":[],"risk":"med"}]}"#;
let gh = r#"
set -eu
printf '%s\n' "$*" >> "$SPAR_FAKE_GH_LOG"
if [ "$1" = api ] && [ "$2" = --paginate ]; then
printf '%s\n' '[{"body":"- Tracks checklist work\n\n<!-- spar:comment -->"}]'
exit 0
fi
if [ "$1" = api ]; then
printf '%s\n' 'issue'
exit 0
fi
if [ "$1" = issue ] && [ "$2" = view ]; then
reads=0
if [ -f "$SPAR_FAKE_ISSUE_READS" ]; then
reads=$(sed -n '1p' "$SPAR_FAKE_ISSUE_READS")
fi
reads=$((reads + 1))
printf '%s\n' "$reads" > "$SPAR_FAKE_ISSUE_READS"
if [ "$reads" -eq 3 ]; then
printf '%s\n' 'tracker reread failed' >&2
exit 1
fi
printf '%s\n' '{"number":42,"title":"Tracker","body":"- [ ] Create child work","labels":[],"state":"OPEN","url":"https://github.com/example/project/issues/42"}'
exit 0
fi
if [ "$1" = issue ] && [ "$2" = list ]; then
printf '%s\n' '[]'
exit 0
fi
printf 'unexpected gh call: %s\n' "$*" >&2
exit 64
"#;
let (config, path) = fake_commands(&fx, answer, gh);
let screen = fx.dir.join("fake-bin").join("screen");
let command = serde_json::to_string(screen.to_str().unwrap()).unwrap();
std::fs::write(
&config,
format!(
"[agents.a]\ncommand = [{command}]\n\n[agents.b]\ncommand = [{command}]\n\n\
[loop]\ndecompose_trackers = true\n"
),
)
.unwrap();
let calls = fx.dir.join("gh-calls.log");
let reads = fx.dir.join("issue-reads");
let (ok, out, err) = spar_with_env(
&[
"run",
"42",
"--config",
config.to_str().unwrap(),
"--repo",
fx.work.to_str().unwrap(),
],
&fx.dir,
&[
("PATH", path.as_str()),
("SPAR_FAKE_GH_LOG", calls.to_str().unwrap()),
("SPAR_FAKE_ISSUE_READS", reads.to_str().unwrap()),
],
);
assert!(
!ok,
"the required tracker reread failed, but the command succeeded:\n{out}\n{err}"
);
assert!(
out.contains("nothing scheduled") || err.contains("nothing scheduled"),
"{out}\n{err}"
);
assert!(
out.contains("writes: 1 attempted, 0 succeeded, 1 failed"),
"{out}"
);
assert!(err.contains("could not re-read #42"), "{err}");
assert_eq!("3", std::fs::read_to_string(reads).unwrap().trim());
let calls = std::fs::read_to_string(calls).unwrap();
assert!(!calls.contains("issue create"), "{calls}");
assert!(!calls.contains("issue comment"), "{calls}");
assert!(!calls.contains("issue edit"), "{calls}");
}
#[cfg(unix)]
#[test]
fn split_counts_each_child_title_that_cannot_reach_an_issue_write() {
let fx = repo("split-title-preflight-failure");
let proposal = r#"{"should_split":true,"reason":"Two separate tasks","stacked":false,"parts":[{"title":"\uD83E\uDD16","body":"First task","files":[]},{"title":"\uD83E\uDD16","body":"Second task","files":[]}]}"#;
let check = r#"{"accept":true,"stacked":false,"strike":[],"reasoning":"They are separate"}"#;
let gh = r#"
set -eu
printf '%s\n' "$*" >> "$SPAR_FAKE_GH_LOG"
if [ "$1" = api ]; then
printf '%s\n' 'issue'
exit 0
fi
if [ "$1" = pr ] && [ "$2" = list ]; then
printf '%s\n' '[]'
exit 0
fi
if [ "$1" = issue ] && [ "$2" = view ]; then
printf '%s\n' '{"number":42,"title":"Large issue","body":"First task. Second task.","labels":[],"state":"OPEN","url":"https://github.com/example/project/issues/42"}'
exit 0
fi
printf 'unexpected gh call: %s\n' "$*" >&2
exit 64
"#;
let (config, path) = fake_commands(&fx, proposal, gh);
let check_command = fx.dir.join("fake-bin").join("check");
executable(
&check_command,
&format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", check),
);
let propose_command =
serde_json::to_string(fx.dir.join("fake-bin").join("screen").to_str().unwrap()).unwrap();
let check_command = serde_json::to_string(check_command.to_str().unwrap()).unwrap();
std::fs::write(
&config,
format!(
"[agents.a]\ncommand = [{propose_command}]\n\n[agents.b]\ncommand = [{check_command}]\n"
),
)
.unwrap();
let calls = fx.dir.join("gh-calls.log");
let (ok, out, err) = spar_with_env(
&[
"split",
"42",
"--config",
config.to_str().unwrap(),
"--repo",
fx.work.to_str().unwrap(),
],
&fx.dir,
&[
("PATH", path.as_str()),
("SPAR_FAKE_GH_LOG", calls.to_str().unwrap()),
],
);
assert!(
!ok,
"both required child title preflights failed, but the command succeeded:\n{out}\n{err}"
);
assert!(
out.contains("writes: 2 attempted, 0 succeeded, 2 failed"),
"{out}"
);
let calls = std::fs::read_to_string(calls).unwrap();
assert!(!calls.contains("issue create"), "{calls}");
assert!(!calls.contains("issue comment"), "{calls}");
assert!(!calls.contains("issue edit"), "{calls}");
}
/// The watermark is what makes the "leave a thread spar disagreed with open"
/// decision terminate: without it, an unresolved thread reads as unanswered
/// forever and spar re-argues every point it lost, once per run.
#[test]
fn a_checkin_watermark_round_trips_and_prunes_with_the_rest() {
use spar::model::Answered;
let fx = repo("checkin-state");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let mut seen = Answered {
version: 1,
..Answered::default()
};
seen.seen
.insert("thread:PRRT_kwABC".into(), "PRRC_kw9".into());
seen.seen
.insert("comment:5455795654".into(), "5455795654".into());
let path = repo.checkin_state_path(108);
spar::repo::write_json_atomic(&path, &seen).unwrap();
let back: Answered = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
Some(&"PRRC_kw9".to_string()),
back.seen.get("thread:PRRT_kwABC")
);
assert_eq!(2, back.seen.len());
// It lives beside the resume state so housekeeping reaches it, and it is
// named apart so `review::persist` cannot overwrite a map it knows nothing
// about.
assert_eq!(
repo.state_path(108).parent(),
path.parent(),
"checkin state has to sit where prune_state looks"
);
assert_ne!(repo.state_path(108), path);
assert!(!path.with_extension("json.tmp").exists());
}
#[test]
fn version_and_help_work_without_any_configuration() {
let dir = unique("help");
std::fs::create_dir_all(&dir).unwrap();
let (ok, out, _) = spar(&["--version"], &dir);
assert!(ok);
assert!(out.contains(env!("CARGO_PKG_VERSION")), "{out}");
let (ok, out, _) = spar(&["--help"], &dir);
assert!(ok);
for word in [
"run",
"triage",
"resume",
"followup",
"checkin",
"brainstorm",
"listen",
"init",
"clean",
"doctor",
] {
assert!(out.contains(word), "{word} missing from help:\n{out}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_missing_config_says_what_to_do_about_it() {
let dir = unique("noconfig");
std::fs::create_dir_all(&dir).unwrap();
let (ok, _, err) = spar(&["run", "1", "--config", "nope.toml"], &dir);
assert!(!ok);
assert!(err.contains("nope.toml"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn init_writes_a_config_that_loads_back() {
let dir = unique("init");
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("spar.toml");
let (_, stdout, _) = spar(&["init", "--out", out.to_str().unwrap()], &dir);
if !out.exists() {
// Fewer than two agent CLIs on this machine, which is a legitimate
// outcome and has to be explained rather than crash.
assert!(stdout.contains("missing"), "{stdout}");
let _ = std::fs::remove_dir_all(&dir);
return;
}
let cfg = config::load(Some(&out)).unwrap();
assert_eq!(2, cfg.agents.len());
assert!(
!cfg.loop_cfg.auto_merge,
"a generated config must not auto merge"
);
assert!(cfg.has_agent(&cfg.first_implementor));
// A second run must refuse rather than clobber.
let (ok, _, err) = spar(&["init", "--out", out.to_str().unwrap()], &dir);
assert!(!ok);
assert!(err.contains("already exists"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn quiet_init_still_explains_why_it_refused() {
let dir = unique("quietinit");
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("spar.toml");
std::fs::write(&out, "").unwrap();
let (ok, _, err) = spar(&["--quiet", "init", "--out", out.to_str().unwrap()], &dir);
assert!(!ok, "an existing file must not be overwritten");
assert!(
err.contains("already exists"),
"--quiet must not swallow a refusal: {err:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn doctor_reports_a_missing_agent_binary_without_crashing() {
let dir = unique("doctor");
std::fs::create_dir_all(&dir).unwrap();
let config = dir.join("spar.toml");
std::fs::write(
&config,
"[agents.alpha]\ncommand = [\"definitely-not-installed-xyz\"]\n\
[agents.beta]\ncommand = [\"also-not-installed-xyz\"]\n",
)
.unwrap();
let (ok, out, _) = spar(&["doctor", "--config", config.to_str().unwrap()], &dir);
assert!(!ok, "doctor must exit non-zero when something is missing");
assert!(out.contains("FAIL alpha"), "{out}");
assert!(out.contains("FAIL beta"), "{out}");
let _ = std::fs::remove_dir_all(&dir);
}
/// The correlation warning is the one thing doctor says that no other command
/// says, so an unrelated probe failure must not swallow it.
#[test]
fn doctor_warns_when_both_agents_are_the_same_binary_and_model() {
let dir = unique("doctorcorr");
std::fs::create_dir_all(&dir).unwrap();
let config = dir.join("spar.toml");
std::fs::write(
&config,
"[agents.alpha]\ncommand = [\"/bin/echo\"]\nmodel = \"fable\"\n\
[agents.beta]\ncommand = [\"/bin/echo\"]\nmodel = \"fable\"\n",
)
.unwrap();
let (_, out, _) = spar(&["doctor", "--config", config.to_str().unwrap()], &dir);
assert!(out.contains("WARNING"), "{out}");
assert!(out.contains("alpha") && out.contains("beta"), "{out}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn doctor_stays_quiet_when_the_two_agents_are_genuinely_different() {
let dir = unique("doctorok");
std::fs::create_dir_all(&dir).unwrap();
let config = dir.join("spar.toml");
std::fs::write(
&config,
"[agents.alpha]\ncommand = [\"/bin/echo\"]\nmodel = \"fable\"\n\
[agents.beta]\ncommand = [\"/bin/cat\"]\nmodel = \"fable\"\n",
)
.unwrap();
let (_, out, _) = spar(&["doctor", "--config", config.to_str().unwrap()], &dir);
assert!(!out.contains("WARNING"), "{out}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn clean_on_a_repository_with_nothing_to_clean_says_so() {
let fx = repo("cleannothing");
std::fs::write(fx.work.join("spar.toml"), TWO_AGENTS).unwrap();
let (ok, out, _) = spar(&["clean", "--repo", fx.work.to_str().unwrap()], &fx.work);
assert!(ok);
assert!(out.contains("nothing to clean"), "{out}");
}
#[cfg(unix)]
#[test]
fn clean_counts_a_failed_state_comment_delete() {
let fx = repo("clean-delete-failure");
let gh = r#"
set -eu
printf '%s\n' "$*" >> "$SPAR_FAKE_GH_LOG"
if [ "$1" = pr ] && [ "$2" = list ]; then
printf '%s\n' '[{"number":7}]'
exit 0
fi
if [ "$1" = pr ] && [ "$2" = view ] && [ "$3" = 7 ]; then
printf '%s\n' '{"state":"MERGED"}'
exit 0
fi
if [ "$1" = api ] && [ "$2" = user ]; then
printf '%s\n' 'spar-bot'
exit 0
fi
if [ "$1" = api ] && [ "$2" = --paginate ]; then
printf '%s\n' '[{"id":77,"user":{"login":"spar-bot"},"body":"<!-- spar:state\n{\"version\":2,\"round\":1,\"next_actor\":\"alpha\",\"status\":\"approved\"}\n-->"}]'
exit 0
fi
if [ "$1" = api ] && [ "$2" = -X ] && [ "$3" = DELETE ]; then
printf '%s\n' 'delete failed' >&2
exit 1
fi
printf 'unexpected gh call: %s\n' "$*" >&2
exit 64
"#;
let (config, path) = fake_commands(&fx, "{}", gh);
let calls = fx.dir.join("gh-calls.log");
let (ok, out, err) = spar_with_env(
&[
"clean",
"--repo",
fx.work.to_str().unwrap(),
"--config",
config.to_str().unwrap(),
"--pr-state",
],
&fx.work,
&[
("PATH", path.as_str()),
("SPAR_FAKE_GH_LOG", calls.to_str().unwrap()),
],
);
assert!(!ok, "a failed deletion must fail cleanup:\n{out}\n{err}");
assert!(out.contains("nothing removed"), "{out}");
assert!(
!out.contains("removed state comment on PR #7"),
"a failed deletion was reported as removed: {out}"
);
assert!(
out.contains("writes: 1 attempted, 0 succeeded, 1 failed"),
"{out}"
);
assert!(
err.contains("could not remove state comment on PR #7"),
"{err}"
);
assert!(
std::fs::read_to_string(calls)
.unwrap()
.lines()
.any(|line| line.starts_with("api -X DELETE ")),
"the test must reach the DELETE path"
);
}
// ---------------------------------------------------------------------------
// Staying out of the way
// ---------------------------------------------------------------------------
/// spar's scratch directories live inside the target repo. Leaving them
/// untracked-but-visible makes `git status` noisy in somebody else's project,
/// and writing to a tracked .gitignore would be spar editing their repo.
#[test]
fn spar_excludes_its_own_scratch_directories_from_git_status() {
let fx = repo("exclude");
let _ = Repo::open(&fx.work, &cfg()).unwrap();
let exclude =
std::fs::read_to_string(fx.work.join(".git").join("info").join("exclude")).unwrap();
assert!(exclude.contains("/.spar-worktrees/"), "{exclude}");
assert!(exclude.contains("/.spar/"), "{exclude}");
// And the repo really is clean once spar has written its state there.
let repo = Repo::open(&fx.work, &cfg()).unwrap();
repo.append_local_followup("A note", "body");
repo.record_branch("issue-1", "issue", 1);
assert_eq!("", git(&fx.work, &["status", "--porcelain"]).trim());
}
#[test]
fn the_exclude_entries_are_written_once_not_on_every_open() {
let fx = repo("excludeonce");
for _ in 0..3 {
let _ = Repo::open(&fx.work, &cfg()).unwrap();
}
let exclude =
std::fs::read_to_string(fx.work.join(".git").join("info").join("exclude")).unwrap();
assert_eq!(1, exclude.matches("/.spar-worktrees/").count(), "{exclude}");
assert_eq!(1, exclude.matches("added by spar").count(), "{exclude}");
}
#[test]
fn a_users_own_exclude_entries_are_preserved() {
let fx = repo("excludekeep");
let path = fx.work.join(".git").join("info").join("exclude");
std::fs::write(&path, "# mine\n/scratch/\n").unwrap();
let _ = Repo::open(&fx.work, &cfg()).unwrap();
let exclude = std::fs::read_to_string(&path).unwrap();
assert!(exclude.contains("/scratch/"), "{exclude}");
assert!(exclude.contains("/.spar/"), "{exclude}");
}
// ---------------------------------------------------------------------------
// The base ref every "did anything happen" check hangs off
// ---------------------------------------------------------------------------
#[test]
fn the_remote_tracking_branch_is_preferred_when_it_resolves() {
let fx = repo("baseref");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
assert_eq!("origin/main", repo.base_ref(&fx.work, "main"));
}
/// Without the fallback, a checkout whose origin was never fetched reports
/// every implementation as "no commits" and throws the agent's work away.
#[test]
fn a_missing_remote_ref_falls_back_to_the_local_branch() {
let fx = repo("baserefmissing");
git(&fx.work, &["remote", "remove", "origin"]);
git(&fx.work, &["update-ref", "-d", "refs/remotes/origin/main"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
assert_eq!("main", repo.base_ref(&fx.work, "main"));
}
#[test]
fn work_on_a_branch_is_still_detected_with_no_remote_ref() {
let fx = repo("nochangesnoremote");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
git(&fx.work, &["checkout", "-q", "-b", "feature"]);
commit(
&fx.work,
"a.txt",
"one\n",
"Real work that must not be discarded",
);
git(&fx.work, &["update-ref", "-d", "refs/remotes/origin/main"]);
assert!(
repo.has_changes(&fx.work, "main"),
"an unfetched origin must not read as an abandoned issue"
);
assert!(repo.diff_stat(&fx.work, "main").contains("1 file changed"));
}
#[test]
fn a_branch_with_no_commits_still_reports_no_changes() {
let fx = repo("trulynochanges");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
git(&fx.work, &["checkout", "-q", "-b", "feature"]);
assert!(!repo.has_changes(&fx.work, "main"));
}
// ---------------------------------------------------------------------------
// Not colonising ordinary directory names
// ---------------------------------------------------------------------------
/// `presets/` is a perfectly ordinary directory name in somebody else's
/// project. Searching it for agent presets made a stray `presets/claude.toml`
/// shadow the built in one, and `spar init` then reported Claude Code as
/// missing while it sat on PATH.
#[test]
fn an_unrelated_presets_directory_in_the_repo_is_ignored() {
let dir = unique("shadow");
std::fs::create_dir_all(dir.join("presets")).unwrap();
std::fs::write(
dir.join("presets").join("claude.toml"),
"name = \"my sampler preset\"\ntemperature = 0.8\n",
)
.unwrap();
std::fs::write(
dir.join("presets").join("codex.toml"),
"name = \"another unrelated thing\"\n",
)
.unwrap();
let (_, out, err) = spar(&["init", "--out", "spar.toml"], &dir);
assert!(
!out.contains("no command") && !err.contains("no command"),
"the built in preset must win:\n{out}\n{err}"
);
// Whatever is installed on this machine, the shadowing file must not have
// turned a resolvable CLI into a missing one.
assert!(!err.contains("unknown preset"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
/// The documented override location still works.
#[test]
fn a_preset_override_under_the_spar_directory_is_honoured() {
let dir = unique("override");
let presets = dir.join(".spar").join("presets");
std::fs::create_dir_all(&presets).unwrap();
std::fs::write(
presets.join("claude.toml"),
"command = [\"/bin/echo\", \"{prompt}\"]\noutput = \"text\"\n",
)
.unwrap();
std::fs::write(
dir.join("spar.toml"),
"[agents.claude]\npreset = \"claude\"\n[agents.other]\ncommand = [\"/bin/cat\"]\n",
)
.unwrap();
let (_, out, _) = spar(&["doctor", "--config", "spar.toml"], &dir);
// Deliberately not the exit code. `doctor` also checks gh authentication,
// which is an unrelated prerequisite that no CI runner has, and asserting
// on it would make this a test about the environment rather than about
// which binary the preset resolved to.
assert!(
out.contains("/bin/echo"),
"the override was not used:\n{out}"
);
assert!(!out.contains("unknown preset"), "{out}");
let _ = std::fs::remove_dir_all(&dir);
}
// ---------------------------------------------------------------------------
// Never rebuilding over work that already exists
// ---------------------------------------------------------------------------
/// The data loss case behind `spar run 42` on an issue that has already been
/// worked. Deleting the local branch and rebuilding from the base leaves the
/// remote tracking ref intact, so `--force-with-lease` is satisfied and the
/// push quietly replaces the previous round's commits.
#[test]
fn a_remote_branch_with_unaccounted_work_is_not_rebuilt() {
let fx = repo("noclobber");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
// A previous session's work, pushed.
git(&fx.work, &["checkout", "-q", "-b", "issue-42"]);
commit(
&fx.work,
"feature.txt",
"round one\n",
"Implement the feature",
);
git(&fx.work, &["push", "-q", "-u", "origin", "issue-42"]);
git(&fx.work, &["checkout", "-q", "main"]);
let before = git(&fx.work, &["rev-parse", "origin/issue-42"]);
let err = repo.worktree_add(42, "main").unwrap_err().to_string();
assert!(err.contains("issue-42"), "{err}");
assert!(
err.contains("force push"),
"the reason has to be in the message: {err}"
);
assert!(
err.contains("spar resume"),
"the remedy has to be in the message: {err}"
);
assert!(
err.contains("git push origin --delete issue-42"),
"a confirmed remote branch may name the remote remedy: {err}"
);
assert_eq!(
before,
git(&fx.work, &["rev-parse", "origin/issue-42"]),
"the previous work must still be on origin"
);
}
#[test]
fn a_branch_that_matches_the_base_is_not_treated_as_work() {
let fx = repo("noclobber-equal");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
// A branch pushed with nothing on it beyond the base.
git(&fx.work, &["push", "-q", "origin", "main:issue-43"]);
let (path, branch) = repo
.worktree_add(43, "main")
.expect("an empty branch carries no work to lose");
assert_eq!("issue-43", branch);
assert!(path.is_dir());
repo.worktree_remove(43);
}
/// The other half of the same case. An implement call that fails after the
/// agent has committed leaves the work unpushed, so there is no remote branch
/// for the guard above to find and nothing but the reflog if this one deletes
/// the local branch.
#[test]
fn a_local_branch_with_unpushed_work_is_not_rebuilt() {
let fx = repo("noclobber-local");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
git(&fx.work, &["checkout", "-q", "-b", "issue-45"]);
commit(
&fx.work,
"feature.txt",
"round one\n",
"Implement the feature",
);
let before = git(&fx.work, &["rev-parse", "issue-45"]);
git(&fx.work, &["checkout", "-q", "main"]);
let err = repo.worktree_add(45, "main").unwrap_err().to_string();
assert!(
err.contains("Implement the feature"),
"the message has to say what is sitting there: {err}"
);
assert!(
err.contains("git branch -D issue-45"),
"the remedy has to be in the message: {err}"
);
assert_eq!(
before,
git(&fx.work, &["rev-parse", "issue-45"]),
"the unpushed work must still be reachable"
);
}
#[test]
fn a_local_branch_that_matches_the_base_is_not_treated_as_work() {
let fx = repo("noclobber-local-equal");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
git(&fx.work, &["branch", "issue-46", "main"]);
let (path, branch) = repo
.worktree_add(46, "main")
.expect("an empty branch carries no work to lose");
assert_eq!("issue-46", branch);
assert!(path.is_dir());
repo.worktree_remove(46);
}
/// `git commit --allow-empty-message` is legal and leaves a commit with no
/// subject line, which is exactly what a listing of subjects drops. The guards
/// count commits instead, or a branch whose work happens to be unnamed reads as
/// an empty branch and gets rebuilt over.
#[test]
fn a_commit_with_no_message_is_still_work() {
let fx = repo("noclobber-blank");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
git(&fx.work, &["checkout", "-q", "-b", "issue-47"]);
std::fs::write(fx.work.join("feature.txt"), "round one\n").unwrap();
git(&fx.work, &["add", "."]);
git(
&fx.work,
&["commit", "-q", "--allow-empty-message", "-m", ""],
);
let before = git(&fx.work, &["rev-parse", "issue-47"]);
git(&fx.work, &["checkout", "-q", "main"]);
let err = repo.worktree_add(47, "main").unwrap_err().to_string();
assert!(err.contains("1 commit(s)"), "{err}");
assert_eq!(
before,
git(&fx.work, &["rev-parse", "issue-47"]),
"the unpushed work must still be reachable"
);
}
#[test]
fn a_pushed_commit_with_no_message_is_still_work() {
let fx = repo("noclobber-blank-remote");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
git(&fx.work, &["checkout", "-q", "-b", "issue-48"]);
std::fs::write(fx.work.join("feature.txt"), "round one\n").unwrap();
git(&fx.work, &["add", "."]);
git(
&fx.work,
&["commit", "-q", "--allow-empty-message", "-m", ""],
);
git(&fx.work, &["push", "-q", "-u", "origin", "issue-48"]);
git(&fx.work, &["checkout", "-q", "main"]);
let before = git(&fx.work, &["rev-parse", "origin/issue-48"]);
let err = repo.worktree_add(48, "main").unwrap_err().to_string();
assert!(err.contains("force push"), "{err}");
assert_eq!(
before,
git(&fx.work, &["rev-parse", "origin/issue-48"]),
"the previous work must still be on origin"
);
}
#[test]
fn a_squash_merged_branch_deleted_on_origin_is_rebuilt() {
let fx = repo("noclobber-squash-merged");
let origin = fx.dir.join("origin.git");
git(&fx.work, &["checkout", "-q", "-b", "issue-50"]);
commit(
&fx.work,
"feature.txt",
"round one\n",
"Implement the feature",
);
git(&fx.work, &["push", "-q", "-u", "origin", "issue-50"]);
git(&fx.work, &["checkout", "-q", "main"]);
git(&fx.work, &["merge", "--squash", "issue-50"]);
git(&fx.work, &["commit", "-m", "Squash the feature"]);
git(&fx.work, &["push", "-q", "origin", "main"]);
git(&origin, &["update-ref", "-d", "refs/heads/issue-50"]);
git(&fx.work, &["branch", "-D", "issue-50"]);
git(
&fx.work,
&[
"config",
"--replace-all",
"remote.origin.fetch",
"+refs/heads/main:refs/remotes/origin/main",
],
);
git(&fx.work, &["fetch", "--prune", "origin"]);
assert_eq!(
"1",
git(
&fx.work,
&["rev-list", "--count", "origin/main..origin/issue-50"]
)
.trim(),
"the stale tracking ref must still look ahead of the squash merge"
);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, branch) = repo.worktree_add(50, "main").unwrap();
assert_eq!("issue-50", branch);
assert!(path.is_dir());
assert!(
repo.git_try(&[
"rev-parse",
"--verify",
"--quiet",
"refs/remotes/origin/issue-50",
])
.trim()
.is_empty(),
"the deleted remote branch must be pruned"
);
repo.worktree_remove(50);
}
#[test]
fn a_remote_refresh_failure_keeps_the_existing_issue_branch() {
let fx = repo("noclobber-remote-failure");
let missing_origin = fx.dir.join("missing-origin.git");
git(&fx.work, &["checkout", "-q", "-b", "issue-51"]);
commit(
&fx.work,
"feature.txt",
"round one\n",
"Implement the feature",
);
git(&fx.work, &["push", "-q", "-u", "origin", "issue-51"]);
git(&fx.work, &["checkout", "-q", "main"]);
let before = git(&fx.work, &["rev-parse", "issue-51"]);
git(
&fx.work,
&[
"remote",
"set-url",
"origin",
missing_origin.to_str().unwrap(),
],
);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let err = repo.worktree_add(51, "main").unwrap_err().to_string();
assert!(err.contains("could not refresh origin/main"), "{err}");
assert!(!err.contains("git push origin --delete"), "{err}");
assert_eq!(before, git(&fx.work, &["rev-parse", "issue-51"]));
}
/// What lets the local guard rebuild a branch: a pull request already holds its
/// commits. Reusing a branch name for a second round puts new commits under the
/// old number, and the head that pull request preserved holds the round it was
/// opened from and nothing after it.
#[test]
fn a_pull_request_head_holds_only_what_it_was_opened_from() {
let fx = repo("pr-head-holds");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let pr_head = "refs/spar/test-pr-head";
git(&fx.work, &["checkout", "-q", "-b", "issue-49"]);
commit(
&fx.work,
"feature.txt",
"round one\n",
"First round, merged",
);
// What GitHub still serves at refs/pull/N/head after the branch is gone.
git(&fx.work, &["update-ref", pr_head, "issue-49"]);
assert!(
repo.commits_held_by("issue-49", "main", pr_head),
"the pull request holds the commits it was opened from"
);
commit(
&fx.work,
"feature.txt",
"round two\n",
"Second round, unpushed",
);
assert!(
!repo.commits_held_by("issue-49", "main", pr_head),
"a commit made after the pull request is not on its head"
);
assert!(
!repo.commits_held_by("issue-49", "main", "refs/spar/does-not-exist"),
"a ref that does not resolve cannot vouch for anything"
);
}
#[test]
fn a_fresh_issue_is_unaffected_by_the_guard() {
let fx = repo("noclobber-fresh");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, branch) = repo.worktree_add(44, "main").unwrap();
assert_eq!("issue-44", branch);
assert!(path.join("README.md").is_file());
repo.worktree_remove(44);
}
#[test]
fn a_dirty_issue_worktree_is_not_rebuilt() {
let fx = repo("noclobber-dirty");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(50, "main").unwrap();
std::fs::write(path.join("README.md"), "recover me\n").unwrap();
let err = repo.worktree_add(50, "main").unwrap_err().to_string();
assert!(err.contains("uncommitted changes"), "{err}");
assert!(err.contains(&path.display().to_string()), "{err}");
assert_eq!(
"recover me\n",
std::fs::read_to_string(path.join("README.md")).unwrap()
);
repo.worktree_remove(50);
}
#[test]
fn an_untracked_issue_file_is_not_deleted_by_a_retry() {
let fx = repo("noclobber-untracked");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(51, "main").unwrap();
std::fs::write(path.join("new-source.rs"), "recover me\n").unwrap();
let err = repo.worktree_add(51, "main").unwrap_err().to_string();
assert!(err.contains("uncommitted changes"), "{err}");
assert_eq!(
"recover me\n",
std::fs::read_to_string(path.join("new-source.rs")).unwrap()
);
repo.worktree_remove(51);
}
#[test]
fn an_unreadable_issue_worktree_is_kept() {
let fx = repo("noclobber-unreadable");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(53, "main").unwrap();
let marker = path.join("recover.txt");
std::fs::write(&marker, "recover me\n").unwrap();
let git_file = path.join(".git");
let metadata = std::fs::read_to_string(&git_file).unwrap();
std::fs::write(&git_file, "broken\n").unwrap();
let err = repo.worktree_add(53, "main").unwrap_err().to_string();
assert!(err.contains("could not verify"), "{err}");
assert_eq!("recover me\n", std::fs::read_to_string(&marker).unwrap());
std::fs::write(&git_file, metadata).unwrap();
repo.worktree_remove(53);
}
#[test]
fn staged_files_are_still_uncommitted_work() {
let fx = repo("uncommitted-staged");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(52, "main").unwrap();
std::fs::write(path.join("README.md"), "staged\n").unwrap();
git(&path, &["add", "README.md"]);
let err = repo.worktree_add(52, "main").unwrap_err().to_string();
assert!(err.contains("uncommitted changes"), "{err}");
assert_eq!(
"staged\n",
std::fs::read_to_string(path.join("README.md")).unwrap()
);
repo.worktree_remove(52);
}
#[test]
fn a_clean_foreign_repository_at_an_issue_path_is_not_removed() {
let fx = repo("noclobber-foreign");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let path = fx.work.join(".spar-worktrees").join("issue-54");
std::fs::create_dir_all(&path).unwrap();
git(&path, &["init", "-b", "main"]);
git(&path, &["config", "user.email", "spar@example.invalid"]);
git(&path, &["config", "user.name", "spar test"]);
git(&path, &["config", "commit.gpgsign", "false"]);
commit(&path, "recovery.txt", "foreign history\n", "foreign commit");
let before = git(&path, &["rev-parse", "HEAD"]);
let err = repo.worktree_add(54, "main").unwrap_err().to_string();
assert!(err.contains("not a worktree owned"), "{err}");
assert_eq!(before, git(&path, &["rev-parse", "HEAD"]));
assert_eq!(
"foreign history\n",
std::fs::read_to_string(path.join("recovery.txt")).unwrap()
);
}
#[test]
fn a_foreign_repository_at_a_stale_registered_path_is_not_removed() {
let fx = repo("noclobber-stale-foreign");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(56, "main").unwrap();
let displaced = fx.work.join("displaced-issue-56");
std::fs::rename(&path, &displaced).unwrap();
std::fs::create_dir_all(&path).unwrap();
git(&path, &["init", "-b", "main"]);
git(&path, &["config", "user.email", "spar@example.invalid"]);
git(&path, &["config", "user.name", "spar test"]);
git(&path, &["config", "commit.gpgsign", "false"]);
commit(&path, "recovery.txt", "foreign history\n", "foreign commit");
let before = git(&path, &["rev-parse", "HEAD"]);
let err = repo.worktree_add(56, "main").unwrap_err().to_string();
assert!(err.contains("not a worktree owned"), "{err}");
assert_eq!(before, git(&path, &["rev-parse", "HEAD"]));
assert_eq!(
"foreign history\n",
std::fs::read_to_string(path.join("recovery.txt")).unwrap()
);
}
#[cfg(unix)]
#[test]
fn a_symlinked_issue_path_cannot_delete_a_personal_worktree() {
use std::os::unix::fs::symlink;
let fx = repo("noclobber-symlinked-worktree");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let personal = fx.dir.join("personal-worktree");
git(
&fx.work,
&[
"worktree",
"add",
"-b",
"personal-57",
personal.to_str().unwrap(),
"main",
],
);
commit(
&personal,
"personal.txt",
"keep me\n",
"personal worktree commit",
);
let path = fx.work.join(".spar-worktrees").join("issue-57");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
symlink(&personal, &path).unwrap();
repo.worktree_remove(57);
assert_eq!(
"keep me\n",
std::fs::read_to_string(personal.join("personal.txt")).unwrap()
);
assert!(std::fs::symlink_metadata(&path)
.unwrap()
.file_type()
.is_symlink());
let err = repo.worktree_add(57, "main").unwrap_err().to_string();
assert!(err.contains("not a worktree owned"), "{err}");
assert!(personal.exists());
}
#[test]
fn a_clean_autocrlf_worktree_can_be_rebuilt() {
let fx = repo("clean-autocrlf-worktree");
commit(
&fx.work,
".gitattributes",
"* text\n",
"set text attributes",
);
git(&fx.work, &["push", "-q", "origin", "main"]);
git(&fx.work, &["config", "core.autocrlf", "true"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(58, "main").unwrap();
assert!(std::fs::read(path.join("README.md"))
.unwrap()
.windows(2)
.any(|pair| pair == b"\r\n"));
let (rebuilt, _) = repo.worktree_add(58, "main").unwrap();
assert!(rebuilt.is_dir());
repo.worktree_remove(58);
}
#[cfg(unix)]
#[test]
fn a_clean_filemode_false_worktree_can_be_rebuilt() {
use std::os::unix::fs::PermissionsExt;
let fx = repo("clean-filemode-worktree");
let script = fx.work.join("script.sh");
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
let mut permissions = std::fs::metadata(&script).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&script, permissions).unwrap();
git(&fx.work, &["add", "script.sh"]);
git(&fx.work, &["commit", "-m", "add script"]);
git(&fx.work, &["push", "-q", "origin", "main"]);
git(&fx.work, &["config", "core.filemode", "false"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (_path, _) = repo.worktree_add(59, "main").unwrap();
let (rebuilt, _) = repo.worktree_add(59, "main").unwrap();
assert!(rebuilt.is_dir());
repo.worktree_remove(59);
}
#[cfg(unix)]
#[test]
fn a_mode_only_change_is_retained_when_filemode_is_false() {
use std::os::unix::fs::PermissionsExt;
let fx = repo("changed-filemode-worktree");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(61, "main").unwrap();
let linked_script = path.join("README.md");
let mut permissions = std::fs::metadata(&linked_script).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&linked_script, permissions).unwrap();
git(&path, &["config", "core.filemode", "false"]);
assert!(git(&path, &["status", "--porcelain"]).is_empty());
let error = repo.worktree_add(61, "main").unwrap_err().to_string();
assert!(error.contains("uncommitted changes"), "{error}");
assert!(path.exists());
assert_eq!(
0o755,
std::fs::metadata(&linked_script)
.unwrap()
.permissions()
.mode()
& 0o777
);
}
#[cfg(unix)]
#[test]
fn a_clean_symlinks_false_worktree_can_be_rebuilt() {
use std::os::unix::fs::symlink;
let fx = repo("clean-symlinks-worktree");
std::fs::write(fx.work.join("target.txt"), "target\n").unwrap();
symlink("target.txt", fx.work.join("link.txt")).unwrap();
git(&fx.work, &["add", "target.txt", "link.txt"]);
git(&fx.work, &["commit", "-m", "add link"]);
git(&fx.work, &["push", "-q", "origin", "main"]);
git(&fx.work, &["config", "core.symlinks", "false"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(60, "main").unwrap();
assert!(std::fs::symlink_metadata(path.join("link.txt"))
.unwrap()
.is_file());
let (rebuilt, _) = repo.worktree_add(60, "main").unwrap();
assert!(rebuilt.is_dir());
repo.worktree_remove(60);
}
#[cfg(unix)]
#[test]
fn a_mode_change_to_a_symlink_surrogate_is_retained() {
use std::os::unix::fs::{symlink, PermissionsExt};
let fx = repo("changed-symlink-surrogate");
std::fs::write(fx.work.join("target.txt"), "target\n").unwrap();
symlink("target.txt", fx.work.join("link.txt")).unwrap();
git(&fx.work, &["add", "target.txt", "link.txt"]);
git(&fx.work, &["commit", "-m", "add link"]);
git(&fx.work, &["push", "-q", "origin", "main"]);
git(&fx.work, &["config", "core.symlinks", "false"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(64, "main").unwrap();
let surrogate = path.join("link.txt");
let mut permissions = std::fs::metadata(&surrogate).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&surrogate, permissions).unwrap();
assert!(git(&path, &["status", "--porcelain"]).is_empty());
let error = repo.worktree_add(64, "main").unwrap_err().to_string();
assert!(error.contains("uncommitted changes"), "{error}");
assert_eq!(
0o755,
std::fs::metadata(surrogate).unwrap().permissions().mode() & 0o777
);
}
#[test]
fn an_initialized_submodule_with_ignored_work_is_retained() {
let fx = repo("submodule-recovery-worktree");
add_submodule(&fx, "nested");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(62, "main").unwrap();
git(
&path,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"update",
"--init",
"nested",
],
);
let recovery = path.join("nested/generated/keep.txt");
std::fs::create_dir_all(recovery.parent().unwrap()).unwrap();
std::fs::write(&recovery, "keep me\n").unwrap();
let error = repo.worktree_add(62, "main").unwrap_err().to_string();
assert!(error.contains("uncommitted changes"), "{error}");
assert_eq!("keep me\n", std::fs::read_to_string(recovery).unwrap());
}
#[test]
fn a_detached_recovery_commit_is_retained() {
let fx = repo("detached-recovery-commit");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(65, "main").unwrap();
git(&path, &["checkout", "--detach"]);
commit(&path, "recovery.txt", "keep me\n", "detached recovery");
let recovery_head = git(&path, &["rev-parse", "HEAD"]);
let error = repo.worktree_add(65, "main").unwrap_err().to_string();
assert!(error.contains("uncommitted changes"), "{error}");
assert_eq!(recovery_head, git(&path, &["rev-parse", "HEAD"]));
assert_eq!(
"keep me\n",
std::fs::read_to_string(path.join("recovery.txt")).unwrap()
);
}
#[test]
fn an_unreferenced_head_reflog_commit_is_retained() {
let fx = repo("head-reflog-recovery");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, branch) = repo.worktree_add(66, "main").unwrap();
git(&path, &["checkout", "--detach"]);
commit(&path, "recovery.txt", "keep me\n", "reflog recovery");
let recovery_head = git(&path, &["rev-parse", "HEAD"]);
git(&path, &["checkout", &branch]);
assert!(git(&path, &["status", "--porcelain"]).is_empty());
let error = repo.worktree_add(66, "main").unwrap_err().to_string();
assert!(error.contains("uncommitted changes"), "{error}");
assert!(path.exists());
git(
&path,
&[
"cat-file",
"-e",
&format!("{}^{{commit}}", recovery_head.trim()),
],
);
}
#[test]
fn a_per_worktree_ref_is_retained() {
let fx = repo("per-worktree-ref");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(67, "main").unwrap();
let tree = git(&path, &["rev-parse", "HEAD^{tree}"]);
let recovery_head = git(
&path,
&[
"commit-tree",
tree.trim(),
"-p",
"HEAD",
"-m",
"per-worktree recovery",
],
);
git(
&path,
&["update-ref", "refs/worktree/recovery", recovery_head.trim()],
);
let error = repo.worktree_add(67, "main").unwrap_err().to_string();
assert!(error.contains("uncommitted changes"), "{error}");
assert_eq!(
recovery_head.trim(),
git(&path, &["rev-parse", "refs/worktree/recovery"]).trim()
);
}
#[test]
fn per_worktree_configuration_is_retained() {
let fx = repo("per-worktree-config");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(68, "main").unwrap();
git(&path, &["config", "extensions.worktreeConfig", "true"]);
git(&path, &["config", "--worktree", "recovery.value", "keep"]);
let error = repo.worktree_add(68, "main").unwrap_err().to_string();
assert!(error.contains("uncommitted changes"), "{error}");
assert_eq!(
"keep",
git(&path, &["config", "--worktree", "--get", "recovery.value"]).trim()
);
}
#[test]
fn an_unreferenced_orig_head_commit_is_retained() {
let fx = repo("orig-head-recovery");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(69, "main").unwrap();
let tree = git(&path, &["rev-parse", "HEAD^{tree}"]);
let recovery_head = git(
&path,
&[
"commit-tree",
tree.trim(),
"-p",
"HEAD",
"-m",
"original head recovery",
],
);
git(&path, &["update-ref", "ORIG_HEAD", recovery_head.trim()]);
let error = repo.worktree_add(69, "main").unwrap_err().to_string();
assert!(error.contains("uncommitted changes"), "{error}");
assert_eq!(
recovery_head.trim(),
git(&path, &["rev-parse", "ORIG_HEAD"]).trim()
);
}
#[test]
fn full_clean_keeps_an_unrecorded_issue_shaped_worktree() {
let fx = repo("clean-all-unrecorded-issue");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let path = fx.work.join(".spar-worktrees/issue-63");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
git(
&fx.work,
&[
"worktree",
"add",
"-b",
"issue-63",
path.to_str().unwrap(),
"main",
],
);
let before = git(&path, &["rev-parse", "HEAD"]);
let removed = repo.prune_worktrees(true);
assert!(removed.is_empty(), "{removed:?}");
assert!(path.is_dir());
assert_eq!(before, git(&path, &["rev-parse", "HEAD"]));
}
#[test]
fn full_clean_keeps_an_unrecorded_review_shaped_worktree() {
let fx = repo("clean-all-unrecorded-review");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let path = fx.work.join(".spar-worktrees/review-64");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
git(
&fx.work,
&[
"worktree",
"add",
"--detach",
path.to_str().unwrap(),
"main",
],
);
let before = git(&path, &["rev-parse", "HEAD"]);
let removed = repo.prune_worktrees(true);
assert!(removed.is_empty(), "{removed:?}");
assert!(path.is_dir());
assert_eq!(before, git(&path, &["rev-parse", "HEAD"]));
}
#[test]
fn a_same_named_tag_cannot_hide_an_unpushed_issue_commit() {
let fx = repo("noclobber-tagged-issue");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let (path, _) = repo.worktree_add(55, "main").unwrap();
commit(&path, "feature.txt", "unique\n", "unique issue work");
let before = git(&path, &["rev-parse", "HEAD"]);
git(&fx.work, &["tag", "issue-55", "refs/heads/main"]);
let err = repo.worktree_add(55, "main").unwrap_err().to_string();
assert!(err.contains("local branch issue-55"), "{err}");
assert_eq!(before, git(&path, &["rev-parse", "HEAD"]));
repo.worktree_remove(55);
}
#[test]
fn a_dirty_pr_worktree_is_not_rebuilt() {
let fx = repo("noclobber-pr-dirty");
git(&fx.work, &["checkout", "-q", "-b", "feature-60"]);
commit(&fx.work, "feature.txt", "remote\n", "remote head");
git(&fx.work, &["push", "-q", "-u", "origin", "feature-60"]);
git(&fx.work, &["checkout", "-q", "main"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let view = pr(60, "feature-60");
let (path, _) = repo.worktree_for_pr(&view).unwrap();
std::fs::write(path.join("README.md"), "recover me\n").unwrap();
let err = repo.worktree_for_pr(&view).unwrap_err().to_string();
assert!(err.contains("uncommitted changes"), "{err}");
assert_eq!(
"recover me\n",
std::fs::read_to_string(path.join("README.md")).unwrap()
);
repo.release_pr_worktree(60);
}
#[test]
fn ignored_output_does_not_hide_ordinary_untracked_pr_work() {
let fx = repo("noclobber-pr-untracked");
git(&fx.work, &["checkout", "-q", "-b", "feature-63"]);
commit(&fx.work, ".gitignore", "local-output/\n", "ignore output");
git(&fx.work, &["push", "-q", "-u", "origin", "feature-63"]);
git(&fx.work, &["checkout", "-q", "main"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let view = pr(63, "feature-63");
let (path, _) = repo.worktree_for_pr(&view).unwrap();
std::fs::create_dir_all(path.join("local-output")).unwrap();
std::fs::write(path.join("local-output/cache"), "cache\n").unwrap();
std::fs::write(path.join("new-source.txt"), "recover me\n").unwrap();
let error = repo.worktree_for_pr(&view).unwrap_err().to_string();
assert!(error.contains("uncommitted changes"), "{error}");
assert_eq!("pr-63\n", git(&path, &["branch", "--show-current"]));
assert_eq!(
"recover me\n",
std::fs::read_to_string(path.join("new-source.txt")).unwrap()
);
}
#[test]
fn a_failed_pr_worktree_move_preserves_the_original_branch_name() {
let fx = repo("locked-pr-output");
git(&fx.work, &["checkout", "-q", "-b", "feature-64"]);
commit(&fx.work, ".gitignore", "local-output/\n", "ignore output");
git(&fx.work, &["push", "-q", "-u", "origin", "feature-64"]);
git(&fx.work, &["checkout", "-q", "main"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let view = pr(64, "feature-64");
let (path, _) = repo.worktree_for_pr(&view).unwrap();
std::fs::create_dir_all(path.join("local-output")).unwrap();
std::fs::write(path.join("local-output/cache"), "keep me\n").unwrap();
git(&fx.work, &["worktree", "lock", path.to_str().unwrap()]);
let error = repo.worktree_for_pr(&view).unwrap_err().to_string();
assert!(error.contains("could not retain"), "{error}");
assert_eq!("pr-64\n", git(&path, &["branch", "--show-current"]));
assert_eq!(
"keep me\n",
std::fs::read_to_string(path.join("local-output/cache")).unwrap()
);
git(&fx.work, &["worktree", "unlock", path.to_str().unwrap()]);
repo.worktree_for_pr(&view).unwrap();
}
#[test]
fn an_unpushed_pr_worktree_commit_is_not_rebuilt() {
let fx = repo("noclobber-pr-commit");
git(&fx.work, &["checkout", "-q", "-b", "feature-61"]);
commit(&fx.work, "feature.txt", "remote\n", "remote head");
git(&fx.work, &["push", "-q", "-u", "origin", "feature-61"]);
git(&fx.work, &["checkout", "-q", "main"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let view = pr(61, "feature-61");
let (path, _) = repo.worktree_for_pr(&view).unwrap();
commit(&path, "feature.txt", "local\n", "local repair");
let before = git(&path, &["rev-parse", "HEAD"]);
let err = repo.worktree_for_pr(&view).unwrap_err().to_string();
assert!(err.contains("local commit"), "{err}");
assert!(err.contains(&path.display().to_string()), "{err}");
assert_eq!(before, git(&path, &["rev-parse", "HEAD"]));
assert!(!repo.release_pr_worktree(61));
assert_eq!(before, git(&path, &["rev-parse", "HEAD"]));
}
#[test]
fn a_same_named_tag_cannot_hide_an_unpushed_pr_commit() {
let fx = repo("noclobber-tagged-pr");
git(&fx.work, &["checkout", "-q", "-b", "feature-62"]);
commit(&fx.work, "feature.txt", "remote\n", "remote head");
git(&fx.work, &["push", "-q", "-u", "origin", "feature-62"]);
git(&fx.work, &["checkout", "-q", "main"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let view = pr(62, "feature-62");
let (path, _) = repo.worktree_for_pr(&view).unwrap();
commit(&path, "feature.txt", "local\n", "local repair");
let before = git(&path, &["rev-parse", "HEAD"]);
git(
&fx.work,
&["tag", "pr-62", "refs/remotes/origin/feature-62"],
);
let err = repo.worktree_for_pr(&view).unwrap_err().to_string();
assert!(err.contains("local commit"), "{err}");
assert_eq!(before, git(&path, &["rev-parse", "HEAD"]));
repo.release_pr_worktree(62);
}
/// The one claim in a report that nothing checked.
///
/// The design's rule is that a prompt is not a permission and every claim an
/// agent makes is checked mechanically where it can be. "I ran the tests" was
/// the exception: the schema records it and spar pushed on the agent's word.
/// Every round after a broken push is a round spent on a branch that should not
/// have been pushed.
#[test]
fn a_failing_check_stops_the_push_and_keeps_the_worktree() {
let fx = repo("check-gate");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let mut config = cfg();
// The check fails, and so does the one fix the agent is given.
config.loop_cfg.check = spar::config::CheckCommand::Line("false".into());
let answer = r#"{"summary":"changed it","problem":"broken","changes":[],"testing":["ran the tests"],"notes":null}"#;
let script = format!("printf 'changed\\n' > README.md; printf '%s\\n' '{answer}'");
config.agents[0].command = vec![
CommandPart::One("/bin/sh".into()),
CommandPart::One("-c".into()),
CommandPart::One(script),
];
let agents: Vec<Agent> = config.agents.iter().cloned().map(Agent::new).collect();
let item = PlanItem {
issue: 71,
title: "Bound the retry loop".into(),
complexity: Complexity::S,
risk: Risk::Low,
depends_on: Vec::new(),
reason: "worth doing".into(),
};
let issue = Issue {
number: 71,
title: item.title.clone(),
body: Some("Make the change".into()),
state: "OPEN".into(),
state_reason: None,
url: "https://example.invalid/issues/71".into(),
labels: Vec::new(),
author: None,
comments: Vec::new(),
};
let state = run_issue(&agents, &config, &repo, &item, &issue);
let path = fx.work.join(".spar-worktrees").join("issue-71");
assert_eq!(Status::Error, state.status);
assert!(
state.notes.iter().any(|n| n.contains("still fails")),
"the report has to carry the failure: {:?}",
state.notes
);
assert!(path.is_dir(), "the worktree was not kept for recovery");
// The work is committed and nothing reached origin.
assert_eq!("changed it\n", git(&path, &["log", "-1", "--format=%s"]));
let remote = git(&fx.work, &["ls-remote", "--heads", "origin"]);
assert!(
!remote.contains("issue-71"),
"a branch that does not pass was pushed: {remote}"
);
repo.worktree_remove(71);
}
#[test]
fn a_structured_implementation_is_committed_before_review() {
let fx = repo("dirty-implementation");
git(&fx.work, &["config", "commit.gpgsign", "true"]);
git(&fx.work, &["config", "gpg.program", "/usr/bin/false"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let mut config = cfg();
let answer = r#"{"summary":"changed it","problem":"broken","changes":[],"testing":[],"notes":"git add could not create index.lock"}"#;
let script = format!("printf 'changed\\n' > README.md; printf '%s\\n' '{answer}'");
config.agents[0].command = vec![
CommandPart::One("/bin/sh".into()),
CommandPart::One("-c".into()),
CommandPart::One(script),
];
let agents: Vec<Agent> = config.agents.iter().cloned().map(Agent::new).collect();
let item = PlanItem {
issue: 70,
title: "Preserve failed edits".into(),
complexity: Complexity::S,
risk: Risk::Low,
depends_on: Vec::new(),
reason: "reproduces the failure".into(),
};
let issue = Issue {
number: 70,
title: item.title.clone(),
body: Some("Make the change".into()),
state: "OPEN".into(),
state_reason: None,
url: "https://example.invalid/issues/70".into(),
labels: Vec::new(),
author: None,
comments: Vec::new(),
};
let state = run_issue(&agents, &config, &repo, &item, &issue);
let path = fx.work.join(".spar-worktrees").join("issue-70");
assert_eq!(Status::Error, state.status);
assert_eq!(
"changed\n",
std::fs::read_to_string(path.join("README.md")).unwrap()
);
assert_eq!("changed it\n", git(&path, &["log", "-1", "--format=%s"]));
assert_eq!("N\n", git(&path, &["log", "-1", "--format=%G?"]));
assert!(git(&path, &["status", "--porcelain"]).is_empty());
assert_ne!(
git(&path, &["rev-parse", "HEAD"]),
git(&path, &["rev-parse", "origin/main"])
);
repo.worktree_remove(70);
}
#[test]
fn an_editing_call_cannot_select_a_new_parent_side_git_filter() {
let fx = repo("changed-gitattributes");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let mut config = cfg();
let answer =
r#"{"summary":"changed attributes","problem":"","changes":[],"testing":[],"notes":""}"#;
let script = format!(
"printf '*.md filter=outside\\n' > .gitattributes; \
printf 'changed\\n' > README.md; printf '%s\\n' '{answer}'"
);
config.agents[0].command = vec![
CommandPart::One("/bin/sh".into()),
CommandPart::One("-c".into()),
CommandPart::One(script),
];
let agents: Vec<Agent> = config.agents.iter().cloned().map(Agent::new).collect();
let item = PlanItem {
issue: 74,
title: "Keep changed attributes out of parent staging".into(),
complexity: Complexity::S,
risk: Risk::Low,
depends_on: Vec::new(),
reason: "protect the staging boundary".into(),
};
let issue = Issue {
number: 74,
title: item.title.clone(),
body: Some("Change the attributes".into()),
state: "OPEN".into(),
state_reason: None,
url: "https://example.invalid/issues/74".into(),
labels: Vec::new(),
author: None,
comments: Vec::new(),
};
let state = run_issue(&agents, &config, &repo, &item, &issue);
let path = fx.work.join(".spar-worktrees").join("issue-74");
assert_eq!(Status::Error, state.status);
assert!(
state
.notes
.iter()
.any(|note| note.contains(".gitattributes")),
"{:?}",
state.notes
);
assert_eq!(
"*.md filter=outside\n",
std::fs::read_to_string(path.join(".gitattributes")).unwrap()
);
assert_eq!(
git(&path, &["rev-parse", "HEAD"]),
git(&path, &["rev-parse", "origin/main"])
);
assert!(!git(&path, &["status", "--porcelain"]).is_empty());
repo.worktree_remove(74);
}
#[test]
fn an_ignored_only_success_is_accepted_without_discarding_the_files() {
let fx = repo("ignored-only-edit");
commit(
&fx.work,
".gitignore",
"generated/\n",
"ignore generated files",
);
git(&fx.work, &["push", "-q", "origin", "main"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let mut config = cfg();
let answer = r#"{"summary":"added fixture","problem":"","changes":[],"testing":[],"notes":""}"#;
let script = format!(
"mkdir -p generated; printf 'keep me\\n' > generated/fixture.txt; \
printf '%s\\n' '{answer}'"
);
config.agents[0].command = vec![
CommandPart::One("/bin/sh".into()),
CommandPart::One("-c".into()),
CommandPart::One(script),
];
let agents: Vec<Agent> = config.agents.iter().cloned().map(Agent::new).collect();
let item = PlanItem {
issue: 75,
title: "Keep an ignored fixture".into(),
complexity: Complexity::S,
risk: Risk::Low,
depends_on: Vec::new(),
reason: "protect ignored work".into(),
};
let issue = Issue {
number: 75,
title: item.title.clone(),
body: Some("Add the ignored fixture".into()),
state: "OPEN".into(),
state_reason: None,
url: "https://example.invalid/issues/75".into(),
labels: Vec::new(),
author: None,
comments: Vec::new(),
};
let state = run_issue(&agents, &config, &repo, &item, &issue);
let path = fx.work.join(".spar-worktrees").join("issue-75");
assert_eq!(Status::Abandoned, state.status);
assert_eq!(
"keep me\n",
std::fs::read_to_string(path.join("generated/fixture.txt")).unwrap()
);
assert_eq!(
git(&path, &["rev-parse", "HEAD"]),
git(&path, &["rev-parse", "origin/main"])
);
let retry = repo.worktree_add(75, "main").unwrap_err().to_string();
assert!(retry.contains("ignored files"), "{retry}");
assert_eq!(
"keep me\n",
std::fs::read_to_string(path.join("generated/fixture.txt")).unwrap()
);
repo.worktree_remove(75);
}
#[test]
fn tracked_and_ignored_edits_are_committed_without_the_ignored_files() {
let fx = repo("tracked-and-ignored-edit");
commit(
&fx.work,
".gitignore",
"manager/public/assets/\n",
"ignore generated files",
);
git(&fx.work, &["push", "-q", "origin", "main"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let mut config = cfg();
let answer = r#"{"summary":"changed it","problem":"","changes":[],"testing":[],"notes":""}"#;
let script = format!(
"printf 'tracked change\\n' > README.md; mkdir -p manager/public/assets; \
printf 'keep me\\n' > manager/public/assets/fixture.txt; printf '%s\\n' '{answer}'"
);
config.agents[0].command = vec![
CommandPart::One("/bin/sh".into()),
CommandPart::One("-c".into()),
CommandPart::One(script),
];
let agents: Vec<Agent> = config.agents.iter().cloned().map(Agent::new).collect();
let item = PlanItem {
issue: 81,
title: "Retain mixed ignored output".into(),
complexity: Complexity::S,
risk: Risk::Low,
depends_on: Vec::new(),
reason: "protect incomplete work".into(),
};
let issue = Issue {
number: 81,
title: item.title.clone(),
body: Some("Make the change".into()),
state: "OPEN".into(),
state_reason: None,
url: "https://example.invalid/issues/81".into(),
labels: Vec::new(),
author: None,
comments: Vec::new(),
};
let state = run_issue(&agents, &config, &repo, &item, &issue);
let path = fx.work.join(".spar-worktrees").join("issue-81");
assert_eq!(Status::Error, state.status);
assert_eq!("changed it\n", git(&path, &["log", "-1", "--format=%s"]));
assert!(!git(&fx.work, &["ls-remote", "--heads", "origin", "issue-81"]).is_empty());
assert!(git(
&path,
&[
"ls-tree",
"-r",
"--name-only",
"HEAD",
"--",
"manager/public/assets/"
]
)
.is_empty());
assert_eq!(
"tracked change\n",
std::fs::read_to_string(path.join("README.md")).unwrap()
);
assert_eq!(
"keep me\n",
std::fs::read_to_string(path.join("manager/public/assets/fixture.txt")).unwrap()
);
assert!(git(&path, &["status", "--porcelain"]).is_empty());
let retry = repo.worktree_add(81, "main").unwrap_err().to_string();
assert!(retry.contains("origin/issue-81"), "{retry}");
repo.worktree_remove(81);
}
#[test]
fn a_direct_commit_with_ignored_output_reaches_push() {
let fx = repo("direct-commit-and-ignored-edit");
commit(
&fx.work,
".gitignore",
"manager/public/assets/\n",
"ignore generated files",
);
git(&fx.work, &["push", "-q", "origin", "main"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let mut config = cfg();
let answer = r#"{"summary":"changed it","problem":"","changes":[],"testing":[],"notes":""}"#;
let script = format!(
"printf 'tracked change\\n' > README.md; git add README.md; \
git commit -q -m 'direct change'; mkdir -p manager/public/assets; \
printf 'keep me\\n' > manager/public/assets/fixture.txt; printf '%s\\n' '{answer}'"
);
config.agents[0].command = vec![
CommandPart::One("/bin/sh".into()),
CommandPart::One("-c".into()),
CommandPart::One(script),
];
let agents: Vec<Agent> = config.agents.iter().cloned().map(Agent::new).collect();
let item = PlanItem {
issue: 82,
title: "Retain ignored output beside a direct commit".into(),
complexity: Complexity::S,
risk: Risk::Low,
depends_on: Vec::new(),
reason: "protect incomplete work".into(),
};
let issue = Issue {
number: 82,
title: item.title.clone(),
body: Some("Make the change".into()),
state: "OPEN".into(),
state_reason: None,
url: "https://example.invalid/issues/82".into(),
labels: Vec::new(),
author: None,
comments: Vec::new(),
};
let state = run_issue(&agents, &config, &repo, &item, &issue);
let path = fx.work.join(".spar-worktrees").join("issue-82");
assert_eq!(Status::Error, state.status);
assert_eq!("direct change\n", git(&path, &["log", "-1", "--format=%s"]));
assert_eq!(
"keep me\n",
std::fs::read_to_string(path.join("manager/public/assets/fixture.txt")).unwrap()
);
assert!(!git(&fx.work, &["ls-remote", "--heads", "origin", "issue-82"]).is_empty());
let retry = repo.worktree_add(82, "main").unwrap_err().to_string();
assert!(retry.contains("origin/issue-82"), "{retry}");
assert_eq!(
"keep me\n",
std::fs::read_to_string(path.join("manager/public/assets/fixture.txt")).unwrap()
);
repo.worktree_remove(82);
}
#[test]
fn ignored_build_artifacts_do_not_stop_a_managed_commit_or_push() {
let fx = repo("managed-commit-with-build-artifacts");
commit(
&fx.work,
".gitignore",
"target/\ndist/\n",
"ignore build output",
);
git(&fx.work, &["push", "-q", "origin", "main"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let mut config = cfg();
let answer = r#"{"summary":"changed it","problem":"","changes":[],"testing":["tests passed"],"notes":""}"#;
let script = format!(
"printf 'tracked change\\n' > README.md; mkdir -p target/debug dist/cli; \
printf 'rust output\\n' > target/debug/artifact; \
printf 'typescript output\\n' > dist/cli/index.js; printf '%s\\n' '{answer}'"
);
config.agents[0].command = vec![
CommandPart::One("/bin/sh".into()),
CommandPart::One("-c".into()),
CommandPart::One(script),
];
let agents: Vec<Agent> = config.agents.iter().cloned().map(Agent::new).collect();
let item = PlanItem {
issue: 83,
title: "Allow ordinary build output".into(),
complexity: Complexity::S,
risk: Risk::Low,
depends_on: Vec::new(),
reason: "testing must not block publication".into(),
};
let issue = Issue {
number: 83,
title: item.title.clone(),
body: Some("Make and test the change".into()),
state: "OPEN".into(),
state_reason: None,
url: "https://example.invalid/issues/83".into(),
labels: Vec::new(),
author: None,
comments: Vec::new(),
};
let state = run_issue(&agents, &config, &repo, &item, &issue);
let path = fx.work.join(".spar-worktrees").join("issue-83");
assert_eq!(Status::Error, state.status);
assert_eq!("changed it\n", git(&path, &["log", "-1", "--format=%s"]));
assert_eq!(
"rust output\n",
std::fs::read_to_string(path.join("target/debug/artifact")).unwrap()
);
assert_eq!(
"typescript output\n",
std::fs::read_to_string(path.join("dist/cli/index.js")).unwrap()
);
assert!(git(
&path,
&[
"ls-tree",
"-r",
"--name-only",
"HEAD",
"--",
"target/debug/artifact",
"dist/cli/index.js"
]
)
.is_empty());
assert!(!git(&fx.work, &["ls-remote", "--heads", "origin", "issue-83"]).is_empty());
assert!(git(&path, &["status", "--porcelain"]).is_empty());
// Build output is not work to recover, so it does not leave the checkout
// behind once the run is finished with it.
assert!(repo.worktree_remove(83));
assert!(!path.exists());
}
#[test]
fn a_decline_that_created_an_ignored_file_keeps_the_worktree() {
let fx = repo("ignored-decline");
commit(
&fx.work,
".gitignore",
"generated/\n",
"ignore generated files",
);
git(&fx.work, &["push", "-q", "origin", "main"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let mut config = cfg();
let answer = r#"{"not_worth_doing":true,"reason":"already handled","summary":"","problem":"","changes":[],"testing":[],"notes":null}"#;
let script = format!(
"mkdir -p generated; printf 'keep me\\n' > generated/fixture.txt; \
printf '%s\\n' '{answer}'"
);
config.agents[0].command = vec![
CommandPart::One("/bin/sh".into()),
CommandPart::One("-c".into()),
CommandPart::One(script),
];
let agents: Vec<Agent> = config.agents.iter().cloned().map(Agent::new).collect();
let item = PlanItem {
issue: 77,
title: "Keep ignored work on decline".into(),
complexity: Complexity::S,
risk: Risk::Low,
depends_on: Vec::new(),
reason: "a decline must not erase files".into(),
};
let issue = Issue {
number: 77,
title: item.title.clone(),
body: Some("Inspect before declining".into()),
state: "OPEN".into(),
state_reason: None,
url: "https://example.invalid/issues/77".into(),
labels: Vec::new(),
author: None,
comments: Vec::new(),
};
let state = run_issue(&agents, &config, &repo, &item, &issue);
let path = fx.work.join(".spar-worktrees").join("issue-77");
assert_eq!(Status::Abandoned, state.status);
assert_eq!(
"keep me\n",
std::fs::read_to_string(path.join("generated/fixture.txt")).unwrap()
);
repo.worktree_remove(77);
}
#[test]
fn a_failed_implementation_keeps_its_files_and_diagnostic() {
let fx = repo("failed-implementation-files");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let mut config = cfg();
config.agents[0].command = vec![
CommandPart::One("/bin/sh".into()),
CommandPart::One("-c".into()),
CommandPart::One(
"printf 'recover me\\n' > README.md; printf 'refused after editing\\n' >&2; exit 1"
.into(),
),
];
let agents: Vec<Agent> = config.agents.iter().cloned().map(Agent::new).collect();
let item = PlanItem {
issue: 73,
title: "Keep failed implementation files".into(),
complexity: Complexity::S,
risk: Risk::Low,
depends_on: Vec::new(),
reason: "protect local work".into(),
};
let issue = Issue {
number: 73,
title: item.title.clone(),
body: Some("Make a change".into()),
state: "OPEN".into(),
state_reason: None,
url: "https://example.invalid/issues/73".into(),
labels: Vec::new(),
author: None,
comments: Vec::new(),
};
let state = run_issue(&agents, &config, &repo, &item, &issue);
let path = fx.work.join(".spar-worktrees").join("issue-73");
assert_eq!(Status::Error, state.status);
assert!(
state
.notes
.iter()
.any(|note| note.contains("refused after editing")),
"{:?}",
state.notes
);
assert_eq!(
"recover me\n",
std::fs::read_to_string(path.join("README.md")).unwrap()
);
assert!(!git(&path, &["status", "--porcelain"]).is_empty());
repo.worktree_remove(73);
}
#[test]
fn a_failed_implementation_with_a_clean_commit_continues_to_review() {
let fx = repo("failed-implementation-commit");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let mut config = cfg();
config.agents[0].command = vec![
CommandPart::One("/bin/sh".into()),
CommandPart::One("-c".into()),
CommandPart::One(
"printf 'committed recovery\\n' > README.md; git add README.md; \
git commit -q -m 'durable implementation'; printf 'report failed\\n' >&2; exit 1"
.into(),
),
];
let agents: Vec<Agent> = config.agents.iter().cloned().map(Agent::new).collect();
let item = PlanItem {
issue: 80,
title: "Continue from a durable implementation".into(),
complexity: Complexity::S,
risk: Risk::Low,
depends_on: Vec::new(),
reason: "recover a committed edit".into(),
};
let issue = Issue {
number: 80,
title: item.title.clone(),
body: Some("Make the change".into()),
state: "OPEN".into(),
state_reason: None,
url: "https://example.invalid/issues/80".into(),
labels: Vec::new(),
author: None,
comments: Vec::new(),
};
let state = run_issue(&agents, &config, &repo, &item, &issue);
let path = fx.work.join(".spar-worktrees").join("issue-80");
assert_eq!(Status::Error, state.status);
assert!(
state
.notes
.iter()
.any(|note| note.contains("failed after committing")),
"{:?}",
state.notes
);
assert_eq!(
"durable implementation\n",
git(&path, &["log", "-1", "--format=%s"])
);
assert!(git(&path, &["status", "--porcelain"]).is_empty());
assert!(!path.read_dir().unwrap().flatten().any(|entry| entry
.file_name()
.to_string_lossy()
.starts_with(".spar-recovery-needed-")));
}
#[test]
fn a_recovery_commit_in_the_shared_checkout_is_not_reset() {
let fx = repo("shared-recovery");
git(&fx.work, &["checkout", "-q", "-b", "issue-71"]);
commit(&fx.work, "README.md", "recovered\n", "recover failed work");
let before = git(&fx.work, &["rev-parse", "HEAD"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let mut config = cfg();
config.loop_cfg.worktrees = false;
let agents: Vec<Agent> = config.agents.iter().cloned().map(Agent::new).collect();
let item = PlanItem {
issue: 71,
title: "Keep the recovery commit".into(),
complexity: Complexity::S,
risk: Risk::Low,
depends_on: Vec::new(),
reason: "protect local work".into(),
};
let issue = Issue {
number: 71,
title: item.title.clone(),
body: Some("Do not reset the branch".into()),
state: "OPEN".into(),
state_reason: None,
url: "https://example.invalid/issues/71".into(),
labels: Vec::new(),
author: None,
comments: Vec::new(),
};
let state = run_issue(&agents, &config, &repo, &item, &issue);
assert_eq!(Status::Error, state.status);
assert!(
state
.notes
.iter()
.any(|note| note.contains("Refusing to reset")),
"{:?}",
state.notes
);
assert_eq!(before, git(&fx.work, &["rev-parse", "HEAD"]));
assert_eq!(
"recovered\n",
std::fs::read_to_string(fx.work.join("README.md")).unwrap()
);
}
#[test]
fn an_off_checkout_issue_branch_with_recovery_commits_is_not_reset() {
let fx = repo("shared-target-recovery");
git(&fx.work, &["checkout", "-q", "-b", "issue-78"]);
commit(&fx.work, "recovery.txt", "keep me\n", "recover issue 78");
let before = git(&fx.work, &["rev-parse", "refs/heads/issue-78"]);
git(&fx.work, &["checkout", "-q", "main"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let mut config = cfg();
config.loop_cfg.worktrees = false;
let agents: Vec<Agent> = config.agents.iter().cloned().map(Agent::new).collect();
let item = PlanItem {
issue: 78,
title: "Keep the target branch".into(),
complexity: Complexity::S,
risk: Risk::Low,
depends_on: Vec::new(),
reason: "protect off-checkout work".into(),
};
let issue = Issue {
number: 78,
title: item.title.clone(),
body: Some("Do not reset the existing target branch".into()),
state: "OPEN".into(),
state_reason: None,
url: "https://example.invalid/issues/78".into(),
labels: Vec::new(),
author: None,
comments: Vec::new(),
};
let state = run_issue(&agents, &config, &repo, &item, &issue);
assert_eq!(Status::Error, state.status);
assert!(
state
.notes
.iter()
.any(|note| note.contains("local branch issue-78")),
"{:?}",
state.notes
);
assert_eq!(before, git(&fx.work, &["rev-parse", "refs/heads/issue-78"]));
assert_eq!("main\n", git(&fx.work, &["branch", "--show-current"]));
}
#[test]
fn a_pull_request_head_allows_an_off_checkout_issue_branch_to_reset() {
let fx = repo("shared-target-preserved");
git(&fx.work, &["checkout", "-q", "-b", "issue-79"]);
commit(&fx.work, "published.txt", "preserved\n", "publish issue 79");
let published = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
git(
&fx.work,
&["push", "-q", "origin", "HEAD:refs/pull/790/head"],
);
git(&fx.work, &["checkout", "-q", "main"]);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
repo.record_branch("issue-79", "pr", 790);
let mut config = cfg();
config.loop_cfg.worktrees = false;
let agents: Vec<Agent> = config.agents.iter().cloned().map(Agent::new).collect();
let item = PlanItem {
issue: 79,
title: "Reuse a preserved target branch".into(),
complexity: Complexity::S,
risk: Risk::Low,
depends_on: Vec::new(),
reason: "the old tip remains on its pull request".into(),
};
let issue = Issue {
number: 79,
title: item.title.clone(),
body: Some("Start a new round from the base".into()),
state: "OPEN".into(),
state_reason: None,
url: "https://example.invalid/issues/79".into(),
labels: Vec::new(),
author: None,
comments: Vec::new(),
};
let state = run_issue(&agents, &config, &repo, &item, &issue);
assert_eq!(Status::Error, state.status);
assert!(
state
.notes
.iter()
.all(|note| !note.contains("local branch issue-79")),
"{:?}",
state.notes
);
assert_eq!("issue-79\n", git(&fx.work, &["branch", "--show-current"]));
assert_eq!(
git(&fx.work, &["rev-parse", "origin/main"]),
git(&fx.work, &["rev-parse", "refs/heads/issue-79"])
);
assert_eq!(
published,
git(&fx.work, &["ls-remote", "origin", "refs/pull/790/head"])
.split_whitespace()
.next()
.unwrap_or_default()
);
}
#[test]
fn a_pr_head_lets_the_shared_checkout_advance_after_the_pr_closes() {
let fx = repo("shared-preserved-pr");
git(&fx.work, &["checkout", "-q", "-b", "issue-74"]);
commit(&fx.work, "README.md", "published\n", "publish issue 74");
git(
&fx.work,
&["push", "-q", "origin", "HEAD:refs/pull/740/head"],
);
let repo = Repo::open(&fx.work, &cfg()).unwrap();
repo.record_branch("issue-74", "pr", 740);
let mut config = cfg();
config.loop_cfg.worktrees = false;
let agents: Vec<Agent> = config.agents.iter().cloned().map(Agent::new).collect();
let item = PlanItem {
issue: 75,
title: "Start the next shared issue".into(),
complexity: Complexity::S,
risk: Risk::Low,
depends_on: Vec::new(),
reason: "the prior tip is preserved".into(),
};
let issue = Issue {
number: 75,
title: item.title.clone(),
body: Some("Start from the base".into()),
state: "OPEN".into(),
state_reason: None,
url: "https://example.invalid/issues/75".into(),
labels: Vec::new(),
author: None,
comments: Vec::new(),
};
let state = run_issue(&agents, &config, &repo, &item, &issue);
assert_eq!(Status::Error, state.status);
assert!(
state
.notes
.iter()
.all(|note| !note.contains("Refusing to reset")),
"{:?}",
state.notes
);
assert_eq!(
"issue-75\n",
git(&fx.work, &["branch", "--show-current"]),
"the next issue never reached its implementation branch"
);
assert_eq!(
git(&fx.work, &["rev-parse", "origin/main"]),
git(&fx.work, &["rev-parse", "HEAD"])
);
}
#[test]
fn a_managed_edit_in_the_shared_checkout_is_kept_uncommitted() {
let fx = repo("shared-managed-edit");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let mut config = cfg();
config.loop_cfg.worktrees = false;
let answer =
r#"{"summary":"changed it","problem":"broken","changes":[],"testing":[],"notes":null}"#;
let script = format!("printf 'recover me\\n' > README.md; printf '%s\\n' '{answer}'");
config.agents[0].command = vec![
CommandPart::One("/bin/sh".into()),
CommandPart::One("-c".into()),
CommandPart::One(script),
];
let agents: Vec<Agent> = config.agents.iter().cloned().map(Agent::new).collect();
let item = PlanItem {
issue: 76,
title: "Keep a shared checkout edit".into(),
complexity: Complexity::S,
risk: Risk::Low,
depends_on: Vec::new(),
reason: "do not sweep concurrent files into a commit".into(),
};
let issue = Issue {
number: 76,
title: item.title.clone(),
body: Some("Make a change".into()),
state: "OPEN".into(),
state_reason: None,
url: "https://example.invalid/issues/76".into(),
labels: Vec::new(),
author: None,
comments: Vec::new(),
};
let state = run_issue(&agents, &config, &repo, &item, &issue);
assert_eq!(Status::Error, state.status);
assert!(
state
.notes
.iter()
.any(|note| note.contains("cannot distinguish")),
"{:?}",
state.notes
);
assert_eq!(
git(&fx.work, &["rev-parse", "origin/main"]),
git(&fx.work, &["rev-parse", "HEAD"]),
"the shared edit was committed"
);
assert_eq!(
"recover me\n",
std::fs::read_to_string(fx.work.join("README.md")).unwrap()
);
assert!(!git(&fx.work, &["status", "--porcelain"]).is_empty());
}
// ---------------------------------------------------------------------------
// Reviewing what cannot be pushed to
// ---------------------------------------------------------------------------
/// A pull request from a fork has no branch in the upstream repository, which
/// is why it cannot be resumed. GitHub still serves `refs/pull/N/head` for it,
/// and that is what makes reviewing an outside contribution possible at all.
/// Verified against real GitHub as well: for cli/cli PR 14252, the head branch
/// is absent from the upstream repo while refs/pull/14252/head resolves.
#[test]
fn a_pull_request_head_is_checked_out_without_any_branch() {
let fx = repo("prhead");
let origin = fx.dir.join("origin.git");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
// Somebody else's commit, published only as a pull request head.
git(&fx.work, &["checkout", "-q", "-b", "contributor-work"]);
commit(
&fx.work,
"contributed.txt",
"their change\n",
"Their contribution",
);
let head = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
git(
&fx.work,
&["push", "-q", "origin", "HEAD:refs/pull/42/head"],
);
git(&fx.work, &["checkout", "-q", "main"]);
git(&fx.work, &["branch", "-D", "-q", "contributor-work"]);
// The branch is nowhere in the repository: only the pull request ref is.
let branches = git(
&fx.work,
&["for-each-ref", "refs/heads/", "--format=%(refname)"],
);
assert!(!branches.contains("contributor-work"), "{branches}");
let remote_refs = git(&fx.work, &["ls-remote", origin.to_str().unwrap()]);
assert!(remote_refs.contains("refs/pull/42/head"), "{remote_refs}");
let path = repo
.worktree_for_pr_head(42)
.expect("the head must be reachable");
assert!(path.is_dir(), "{}", path.display());
assert_eq!(
"their change\n",
std::fs::read_to_string(path.join("contributed.txt")).unwrap()
);
assert_eq!(head, git(&path, &["rev-parse", "HEAD"]).trim());
// Detached on purpose: there is nothing here to push, so there should be
// nothing that looks pushable.
assert_eq!(
"HEAD",
git(&path, &["rev-parse", "--abbrev-ref", "HEAD"]).trim(),
"the review checkout must not be on a branch"
);
let after = git(
&fx.work,
&["for-each-ref", "refs/heads/", "--format=%(refname)"],
);
assert!(
!after.contains("pr-42"),
"no branch should have been created: {after}"
);
repo.release_review_worktree(42);
assert!(!path.is_dir(), "the worktree should be gone");
let refs = git(
&fx.work,
&["for-each-ref", "refs/spar/", "--format=%(refname)"],
);
assert_eq!("", refs.trim(), "the parked ref should be gone too: {refs}");
}
#[test]
fn a_missing_pull_request_head_says_what_went_wrong() {
let fx = repo("prheadmissing");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let err = repo.worktree_for_pr_head(999).unwrap_err().to_string();
assert!(err.contains("999"), "{err}");
assert!(
err.contains("refs/pull"),
"the message should explain the mechanism: {err}"
);
}
#[test]
fn a_local_commit_in_a_review_worktree_is_not_rebuilt() {
let fx = repo("prhead-local-commit");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
git(&fx.work, &["push", "-q", "origin", "HEAD:refs/pull/8/head"]);
let path = repo.worktree_for_pr_head(8).unwrap();
commit(
&path,
"review-note.txt",
"recover me\n",
"local review recovery",
);
let before = git(&path, &["rev-parse", "HEAD"]);
let error = repo.worktree_for_pr_head(8).unwrap_err().to_string();
assert!(error.contains("local commit"), "{error}");
assert_eq!(before, git(&path, &["rev-parse", "HEAD"]));
assert_eq!(
"recover me\n",
std::fs::read_to_string(path.join("review-note.txt")).unwrap()
);
repo.release_review_worktree(8);
}
fn exclude_in(work: &std::path::Path, line: &str) {
use std::io::Write;
let path = work.join(".git").join("info").join("exclude");
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.unwrap();
writeln!(file, "{line}").unwrap();
}
/// The wedge. A review leaves build output the old directory list did not
/// recognize, plus the marker written when it refused, and every later review
/// of that pull request was then refused too. Rebuilding is what the next run
/// was going to do to that checkout anyway.
#[test]
fn a_review_worktree_is_rebuilt_over_its_own_leftovers() {
let fx = repo("prhead-rebuild-over-output");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
git(
&fx.work,
&["push", "-q", "origin", "HEAD:refs/pull/11/head"],
);
let path = repo.worktree_for_pr_head(11).unwrap();
// The real case has this rule committed. `info/exclude` is the common Git
// directory's copy, which a linked worktree shares, and it keeps the rule
// out of the comparison as a file in its own right.
exclude_in(&fx.work, "generated/");
std::fs::create_dir_all(path.join("generated")).unwrap();
std::fs::write(path.join("generated/Cargo.lock"), "# generated\n").unwrap();
std::fs::write(
path.join(".spar-recovery-needed-1234-0"),
"an earlier call was refused\n",
)
.unwrap();
let rebuilt = repo
.worktree_for_pr_head(11)
.expect("a review checkout is rebuilt from origin, not preserved");
assert_eq!(path, rebuilt);
assert!(!path.join("generated/Cargo.lock").exists());
assert!(!path.join(".spar-recovery-needed-1234-0").exists());
repo.release_review_worktree(11);
}
/// The relaxation stops at the edge of the review checkout: an ordinary
/// untracked file is still somebody's, and still refuses.
#[test]
fn an_ordinary_untracked_file_still_stops_a_review_rebuild() {
let fx = repo("prhead-untracked-blocks");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
git(
&fx.work,
&["push", "-q", "origin", "HEAD:refs/pull/12/head"],
);
let path = repo.worktree_for_pr_head(12).unwrap();
std::fs::write(path.join("notes.txt"), "keep me\n").unwrap();
let error = repo.worktree_for_pr_head(12).unwrap_err().to_string();
assert!(error.contains("uncommitted changes"), "{error}");
assert_eq!(
"keep me\n",
std::fs::read_to_string(path.join("notes.txt")).unwrap()
);
repo.release_review_worktree(12);
}
#[test]
fn review_worktrees_are_swept_by_clean_all() {
let fx = repo("prheadclean");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
git(&fx.work, &["push", "-q", "origin", "HEAD:refs/pull/7/head"]);
let path = repo.worktree_for_pr_head(7).unwrap();
assert!(path.is_dir());
let removed = repo.prune_worktrees(true);
assert!(removed.iter().any(|r| r == "review-7"), "{removed:?}");
assert!(!path.is_dir());
}
// ---------------------------------------------------------------------------
// The documentation is part of the product
// ---------------------------------------------------------------------------
const EXAMPLE_CONFIG: &str = include_str!("../spar.example.toml");
/// spar.example.toml is the file people copy. If it stops parsing, the first
/// thing a new user does fails, and nothing else in the suite would notice.
#[test]
fn the_example_config_parses() {
let cfg = spar::config::parse(EXAMPLE_CONFIG)
.unwrap_or_else(|e| panic!("spar.example.toml does not parse: {e}"));
assert_eq!(2, cfg.agents.len());
assert!(cfg.has_agent(&cfg.first_implementor));
}
/// It also has to keep saying what the code actually does. `deny_unknown_fields`
/// catches a key the example has and the code dropped; this catches the other
/// direction, a key the code gained and nobody documented. Both matter: an
/// undocumented option is one an error message can tell you to set and you
/// cannot find.
#[test]
fn every_config_option_is_documented_in_the_example() {
fn keys_of<T: serde::Serialize>(value: &T) -> Vec<String> {
let text = toml::to_string(value).expect("serializable");
text.lines()
.filter_map(|l| l.split_once(" = ").map(|(k, _)| k.trim().to_string()))
.filter(|k| !k.is_empty())
.collect()
}
let mut expected: Vec<String> = Vec::new();
expected.extend(keys_of(&spar::config::LoopCfg::default()));
expected.extend(keys_of(&spar::config::StyleCfg::default()));
expected.extend(keys_of(&spar::config::EffortSchedule {
round_1: Some("high".into()),
rest: Some("low".into()),
triage: Some("low".into()),
implement: Some("high".into()),
review_1: Some("high".into()),
review_rest: Some("low".into()),
respond: Some("high".into()),
close: Some("low".into()),
screen: Some("low".into()),
checkin: Some("high".into()),
split: Some("high".into()),
brainstorm: Some("high".into()),
recheck: Some("low".into()),
}));
expected.extend(keys_of(&spar::config::ListenCfg::default()));
// Per-agent options, which a preset normally supplies but a user may set.
expected.extend(
[
"model",
"effort",
"output",
"timeout",
"search_paths",
"system_via",
"message_path",
"message_match",
"command",
"preset",
"models",
"efforts",
"options_note",
]
.map(String::from),
);
// Documented means findable by somebody reading the file. A commented out
// option counts, since the example shows options that are off by default
// without turning them on, and a sub-table counts as documenting its name.
let documented = |key: &str| -> bool {
EXAMPLE_CONFIG.lines().any(|line| {
let bare = line.trim_start().trim_start_matches('#').trim_start();
bare.starts_with(&format!("{key} "))
|| bare.starts_with(&format!("{key}="))
|| bare.starts_with(&format!("[{key}]"))
|| bare.contains(&format!(".{key}]"))
})
};
let missing: Vec<&String> = expected.iter().filter(|key| !documented(key)).collect();
assert!(
missing.is_empty(),
"spar.example.toml does not mention: {missing:?}\n\
Every option the parser accepts has to be findable by somebody reading \
the example config."
);
}
/// `spar init` writes a config too, and it has to parse for the same reason.
#[test]
fn the_generated_config_is_not_stale_against_the_example() {
// Both must at least agree on the options they do mention.
for key in ["max_rounds", "auto_merge", "first_implementor", "worktrees"] {
assert!(
EXAMPLE_CONFIG.contains(key),
"{key} missing from spar.example.toml"
);
}
}
/// `spar init` writes the file most people will ever read. It has to name the
/// values that go in `model` and `effort`, because "..." is exactly the
/// guesswork the command exists to remove.
#[test]
fn the_generated_config_names_the_options_rather_than_guessing() {
let dir = unique("inithints");
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("spar.toml");
let (_, stdout, _) = spar(&["init", "--out", out.to_str().unwrap()], &dir);
assert!(
!stdout.contains("BROKEN"),
"a preset failed to build:\n{stdout}"
);
if !out.exists() {
let _ = std::fs::remove_dir_all(&dir);
return; // fewer than two agent CLIs here, covered by another test
}
let text = std::fs::read_to_string(&out).unwrap();
assert!(!text.contains("\"...\""), "no placeholder values:\n{text}");
for key in [
"keep_worktrees",
"parallel_triage",
"file_nits",
"branch_prefix",
"state_store",
"max_title_chars",
"max_detail_chars",
] {
assert!(
text.contains(key),
"{key} is not offered in the generated config:\n{text}"
);
}
// And it still has to load.
let cfg = spar::config::load(Some(&out)).unwrap();
assert_eq!(2, cfg.agents.len());
let _ = std::fs::remove_dir_all(&dir);
}
/// Every built in preset has to survive being turned into an agent. A preset
/// that does not is reported as BROKEN rather than silently skipped, because
/// skipping it made a malformed preset look like an uninstalled CLI.
#[test]
fn no_builtin_preset_is_broken() {
for name in spar::config::available_presets() {
let raw = spar::config::load_preset(&name)
.unwrap_or_else(|e| panic!("preset {name} does not load: {e}"));
let table = raw.as_table().cloned().expect("a table");
let spec: Result<spar::config::AgentSpec, _> = toml::Value::Table(table).try_into();
spec.unwrap_or_else(|e| panic!("preset {name} does not build: {e}"));
}
}
/// The hint lists are advisory. Validating against them would refuse a model
/// that works the moment a CLI adds one.
#[test]
fn a_model_outside_the_hint_list_is_still_accepted() {
let text = "[agents.a]\npreset = \"claude\"\nmodel = \"some-model-nobody-listed\"\n\
effort = \"invented-effort\"\n[agents.b]\ncommand = [\"x\"]\n";
let cfg = spar::config::parse(text).expect("hints must not be a whitelist");
assert_eq!(
Some("some-model-nobody-listed"),
cfg.spec("a").unwrap().model.as_deref()
);
}
// ---------------------------------------------------------------------------
// Upgrading a config that already exists
// ---------------------------------------------------------------------------
/// `spar init` will not touch an existing config, so without this a release
/// that adds a setting is invisible to anyone already using spar.
#[test]
fn init_update_appends_new_settings_without_touching_the_old_ones() {
let dir = unique("initupdate");
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("spar.toml");
let original = "# my own comment, kept\n[agents.a]\ncommand = [\"/bin/echo\"]\n\
[agents.b]\ncommand = [\"/bin/cat\"]\n\n[loop]\nmax_rounds = 7\n";
std::fs::write(&out, original).unwrap();
let (ok, stdout, _) = spar(&["init", "--out", out.to_str().unwrap(), "--update"], &dir);
assert!(ok, "{stdout}");
let after = std::fs::read_to_string(&out).unwrap();
assert!(
after.starts_with(original),
"existing content was rewritten:\n{after}"
);
assert!(after.contains("# my own comment, kept"));
assert!(
after.contains("max_rounds = 7"),
"a set value must survive untouched"
);
// The appended settings are commented, so nothing takes effect by surprise.
assert!(after.contains("# followups ="), "{after}");
assert!(after.contains("# pr_comments ="), "{after}");
// Still parses, and the value the user set still wins.
let cfg = spar::config::load(Some(&out)).unwrap();
assert_eq!(7, cfg.loop_cfg.max_rounds);
// Idempotent: a second run has nothing to add.
let (ok, stdout, _) = spar(&["init", "--out", out.to_str().unwrap(), "--update"], &dir);
assert!(ok);
assert!(
stdout.contains("already mentions every setting"),
"{stdout}"
);
assert_eq!(
after,
std::fs::read_to_string(&out).unwrap(),
"second run changed the file"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn init_update_refuses_to_lengthen_a_broken_config() {
let dir = unique("initupdatebroken");
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("spar.toml");
std::fs::write(&out, "[agents.a]\nthis is not toml\n").unwrap();
let before = std::fs::read_to_string(&out).unwrap();
let (ok, _, err) = spar(&["init", "--out", out.to_str().unwrap(), "--update"], &dir);
assert!(!ok);
assert!(err.contains("does not parse"), "{err}");
assert_eq!(
before,
std::fs::read_to_string(&out).unwrap(),
"it wrote anyway"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn doctor_names_the_settings_a_config_has_never_heard_of() {
let dir = unique("doctorunset");
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("spar.toml");
std::fs::write(
&out,
"[agents.a]\ncommand = [\"/bin/echo\"]\n[agents.b]\ncommand = [\"/bin/cat\"]\n",
)
.unwrap();
let (_, stdout, _) = spar(&["doctor", "--config", out.to_str().unwrap()], &dir);
assert!(stdout.contains("does not mention"), "{stdout}");
assert!(stdout.contains("pr_comments"), "{stdout}");
assert!(
stdout.contains("init --update"),
"it should say how to fix it:\n{stdout}"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// The example config is the one file that should already mention everything.
#[test]
fn the_example_config_mentions_every_setting() {
let unset = spar::config::unmentioned_options(EXAMPLE_CONFIG);
let names: Vec<String> = unset.iter().map(|o| o.key.clone()).collect();
assert!(
names.is_empty(),
"spar.example.toml never mentions: {names:?}"
);
}
// ---------------------------------------------------------------------------
// Agreeing with a dry run without paying for it twice
// ---------------------------------------------------------------------------
#[test]
fn a_saved_review_round_trips_through_the_repo() {
let fx = repo("pending");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
assert!(
repo.read_pending_comment(366).is_none(),
"nothing saved yet"
);
let review = "Two independent reviews.\n\nneeds changing before merge\n- Something real.";
let path = repo.save_pending_comment(366, review).unwrap();
assert!(path.ends_with("pr-366.md"), "{}", path.display());
assert_eq!(Some(review.to_string()), repo.read_pending_comment(366));
// It lives under .spar, which spar keeps out of the target repo's status.
assert_eq!("", git(&fx.work, &["status", "--porcelain"]).trim());
}
#[test]
fn post_says_so_when_there_is_nothing_saved() {
let fx = repo("postmissing");
std::fs::write(fx.work.join("spar.toml"), TWO_AGENTS).unwrap();
let (ok, _, err) = spar(
&[
"post",
"366",
"--repo",
fx.work.to_str().unwrap(),
"--config",
"spar.toml",
],
&fx.work,
);
assert!(!ok);
assert!(err.contains("no saved review"), "{err}");
assert!(
err.contains("--dry-run"),
"it should say how to produce one: {err}"
);
}
/// The point of the feature: read it, then post exactly what you read, without
/// running the agents again.
#[test]
fn post_dry_run_prints_the_saved_review_without_touching_github() {
let fx = repo("postdry");
std::fs::write(fx.work.join("spar.toml"), TWO_AGENTS).unwrap();
let repo = Repo::open(&fx.work, &cfg()).unwrap();
repo.save_pending_comment(366, "needs changing before merge\n- A real defect.")
.unwrap();
let (ok, out, _) = spar(
&[
"post",
"366",
"--repo",
fx.work.to_str().unwrap(),
"--config",
"spar.toml",
"--dry-run",
],
&fx.work,
);
assert!(ok, "{out}");
assert!(out.contains("A real defect."), "{out}");
}
#[cfg(unix)]
#[test]
fn post_counts_a_failed_duplicate_check_before_the_write() {
let fx = repo("post-preflight-failure");
let gh = r#"
printf '%s\n' "$*" >> "$SPAR_FAKE_GH_LOG"
printf '%s\n' 'comment lookup failed' >&2
exit 1
"#;
let (config, path) = fake_commands(&fx, "{}", gh);
let review = fx.dir.join("review.md");
let calls = fx.dir.join("gh-calls.log");
std::fs::write(&review, "review needs work").unwrap();
let (ok, out, err) = spar_with_env(
&[
"post",
"366",
"--repo",
fx.work.to_str().unwrap(),
"--config",
config.to_str().unwrap(),
"--file",
review.to_str().unwrap(),
],
&fx.work,
&[
("PATH", path.as_str()),
("SPAR_FAKE_GH_LOG", calls.to_str().unwrap()),
],
);
assert!(
!ok,
"a failed safety check must fail the command:\n{out}\n{err}"
);
assert!(
out.contains("writes: 1 attempted, 0 succeeded, 1 failed"),
"{out}"
);
let calls = std::fs::read_to_string(calls).unwrap();
assert!(calls.contains("api --paginate"), "{calls}");
assert!(
!calls.contains("pr comment"),
"the write must not start after its duplicate check fails: {calls}"
);
}
#[cfg(unix)]
#[test]
fn post_counts_a_reported_failure_as_success_when_read_back_finds_it() {
let fx = repo("post-reconciled-success");
let gh = r#"
case "$1 $2" in
"api --paginate")
if [ -f "$SPAR_FAKE_COMMENT_LANDED" ]; then
printf '%s\n' '[{"body":"review landed\n\n<!-- spar:comment -->"}]'
else
printf '%s\n' '[]'
fi
;;
"pr comment")
: > "$SPAR_FAKE_COMMENT_LANDED"
printf '%s\n' 'connection failed after write' >&2
exit 1
;;
*) printf 'unexpected gh call: %s\n' "$*" >&2; exit 1 ;;
esac
"#;
let (config, path) = fake_commands(&fx, "{}", gh);
let review = fx.dir.join("review.md");
let landed = fx.dir.join("comment-landed");
std::fs::write(&review, "review landed").unwrap();
let (ok, out, err) = spar_with_env(
&[
"post",
"366",
"--repo",
fx.work.to_str().unwrap(),
"--config",
config.to_str().unwrap(),
"--file",
review.to_str().unwrap(),
],
&fx.work,
&[
("PATH", path.as_str()),
("SPAR_FAKE_COMMENT_LANDED", landed.to_str().unwrap()),
],
);
assert!(ok, "the read-back proved the comment landed:\n{out}\n{err}");
assert!(
out.contains("writes: 1 attempted, 1 succeeded, 0 failed"),
"{out}"
);
assert!(landed.exists());
}
/// An edited file is still spar's output, so it goes through the same gate.
#[test]
fn posting_a_file_that_breaks_the_style_rules_is_refused() {
let fx = repo("poststyle");
std::fs::write(fx.work.join("spar.toml"), TWO_AGENTS).unwrap();
let edited = fx.work.join("edited.md");
std::fs::write(&edited, "I rewrote this \u{2014} with an em dash.").unwrap();
let (_, _, err) = spar(
&[
"post",
"366",
"--repo",
fx.work.to_str().unwrap(),
"--config",
"spar.toml",
"--file",
edited.to_str().unwrap(),
],
&fx.work,
);
// gh will fail here for lack of a GitHub remote, but the em dash must not
// be what reaches it: the scrub runs first and turns it into a comma.
assert!(
!err.contains('\u{2014}'),
"an em dash reached the API call: {err}"
);
}
#[test]
fn post_refuses_a_file_for_several_pull_requests() {
let fx = repo("postmany");
std::fs::write(fx.work.join("spar.toml"), TWO_AGENTS).unwrap();
let (ok, _, err) = spar(
&[
"post",
"1",
"2",
"--repo",
fx.work.to_str().unwrap(),
"--config",
"spar.toml",
"--file",
"x.md",
],
&fx.work,
);
assert!(!ok);
assert!(err.contains("one pull request"), "{err}");
}
// ---------------------------------------------------------------------------
// What landed after the last round of review
// ---------------------------------------------------------------------------
/// The closing pass is told what nobody has read yet, which is whatever landed
/// after the head the last review recorded.
#[test]
fn what_landed_after_the_last_review_comes_back_from_git() {
let fx = repo("landed");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
commit(&fx.work, "parser.rs", "one\n", "Add the parser");
let audited = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
commit(&fx.work, "empty.rs", "two\n", "Cover the empty input case");
let landed = repo
.commits_since(&fx.work, &audited, "HEAD")
.expect("the recorded head is still on the branch");
assert_eq!(1, landed.len(), "{landed:?}");
assert!(
landed[0].contains("Cover the empty input case"),
"{landed:?}"
);
}
/// Nothing landed is a real answer, and a different one from not being able to
/// tell. The loop keeps today's ending for the first and asks anyway for the
/// second.
#[test]
fn a_head_nothing_was_added_to_reports_nothing_landed() {
let fx = repo("landednothing");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
commit(&fx.work, "parser.rs", "one\n", "Add the parser");
let audited = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
assert_eq!(
Some(Vec::new()),
repo.commits_since(&fx.work, &audited, "HEAD")
);
}
/// `rewrite_commits_if_needed` rewrites every hash from the first offending
/// commit onward, so a head recorded before a round can still be a readable
/// object and no longer be on the branch. `git log old..HEAD` answers that with
/// the whole branch, so without the ancestor check the closing pass would be
/// handed every commit as unread and become the full audit it replaces.
#[test]
fn a_head_that_was_rewritten_off_the_branch_reports_nothing_rather_than_everything() {
let fx = repo("landedrewritten");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
let seed = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
commit(&fx.work, "parser.rs", "one\n", "Add the parser");
let audited = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
commit(&fx.work, "empty.rs", "two\n", "Cover the empty input case");
// What a message rewrite does to a branch: the same trees under new hashes,
// from the first offending commit onward. `audited` is one that moved, so it
// is still a readable object and no longer on the branch.
git(&fx.work, &["reset", "--hard", &seed]);
commit(&fx.work, "parser.rs", "one\n", "Add the parser, tidily");
commit(&fx.work, "empty.rs", "two\n", "Cover the empty input case");
assert!(
!git(&fx.work, &["log", "--format=%H"]).contains(&audited),
"the rewrite has to have taken the recorded head off the branch"
);
assert!(
!git(&fx.work, &["rev-parse", "--verify", "--quiet", &audited])
.trim()
.is_empty(),
"and has to have left it readable, or this tests the wrong failure"
);
assert_eq!(
None,
repo.commits_since(&fx.work, &audited, "HEAD"),
"the recorded head is off the branch, so the harness cannot say what is new"
);
}
// ---------------------------------------------------------------------------
// Work whose author never got to describe it
// ---------------------------------------------------------------------------
/// An implement call that fails on its structured answer leaves commits and no
/// description of them. The commit messages are the only account there is, so
/// the pull request body is written from those rather than the branch being
/// left with nothing pointing at it.
#[test]
fn a_pr_body_is_assembled_from_the_commits_when_the_report_never_came() {
let fx = repo("frombranch");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
commit(&fx.work, "parser.rs", "one\n", "Add the parser");
commit(&fx.work, "empty.rs", "two\n", "Cover the empty input case");
let work = spar::review::from_commits(&repo, &fx.work, "main");
assert!(
!work.not_worth_doing,
"there is work, so it was not declined"
);
assert_eq!(
vec!["Add the parser", "Cover the empty input case"],
work.changes,
"oldest first, as the branch reads"
);
let body = spar::review::pr_body(42, &work, None, &repo.style);
assert!(body.contains("Closes #42"), "{body}");
assert!(body.contains("- Add the parser"), "{body}");
assert!(body.contains("- Cover the empty input case"), "{body}");
assert!(
body.contains("failed after these commits were made"),
"a reviewer has to know this body is not the author's own: {body}"
);
}
#[test]
fn a_branch_with_no_commits_of_its_own_lists_nothing() {
let fx = repo("frombranch-empty");
let repo = Repo::open(&fx.work, &cfg()).unwrap();
assert!(spar::review::from_commits(&repo, &fx.work, "main")
.changes
.is_empty());
}
// ---------------------------------------------------------------------------
// Verdicts and second looks
// ---------------------------------------------------------------------------
/// A fake `gh` for a review of pull request `number` whose head GitHub reports
/// as `head` and whose author is `author`. Reviews, comments and threads are
/// all empty, and a submitted review or comment is accepted and logged.
fn review_gh(number: i64, head: &str, author: &str) -> String {
format!(
r#"set -eu
printf '%s\n' "$*" >> "$SPAR_FAKE_GH_LOG"
case "$1 $2" in
"pr view")
case "$*" in
*"--json author") printf '%s\n' '{{"author":{{"login":"{author}"}}}}' ;;
*"--json headRefOid") printf '%s\n' '{{"headRefOid":"{head}"}}' ;;
*) printf '%s\n' '{{"number":{number},"url":"https://github.com/example/project/pull/{number}","title":"PR {number}","body":"","headRefName":"feature-{number}","baseRefName":"main","state":"OPEN","closingIssuesReferences":[],"isCrossRepository":false}}' ;;
esac ;;
"api user") printf '%s\n' 'viewer' ;;
"api --paginate") printf '%s\n' '[]' ;;
"api graphql") printf '%s\n' '{{"data":{{"repository":{{"pullRequest":{{"reviewThreads":{{"pageInfo":{{"hasNextPage":false,"endCursor":null}},"nodes":[]}}}}}}}}}}' ;;
"api repos/{{owner}}/{{repo}}/issues/{number}") printf 'pr\n' ;;
"api -X") : ;;
"pr comment") : ;;
*) printf 'unexpected gh call: %s\n' "$*" >&2; exit 64 ;;
esac"#
)
}
/// The reviews API call that submits `event` on `commit`, as the fake gh logs
/// it, up to where the body begins. The commit is the point: a review that
/// names none is attached to whatever the head is when it arrives.
fn review_post(number: i64, commit: &str, event: &str) -> String {
format!(
"api -X POST repos/{{owner}}/{{repo}}/pulls/{number}/reviews -f commit_id={commit} -f \
event={event} -f body="
)
}
fn submitted_a_review(log: &str) -> bool {
log.contains("/reviews -f commit_id=")
}
const CLEAN_REVIEW: &str =
r#"{"verdict":"approve","next_action":"hand_back","summary":"fine","findings":[]}"#;
const ONE_BLOCKER_REVIEW: &str = r#"{"verdict":"changes_requested","next_action":"hand_back","summary":"one defect","findings":[{"severity":"blocking","title":"Retry loop never terminates","detail":"the guard compares against Some(0)","file":"src/net.rs:88","in_scope":true,"problem":null,"reproduction":null,"impact":null,"expected":null}]}"#;
fn read_record(fx: &Fixture, number: i64) -> spar::model::ReviewRecord {
let path = fx
.work
.join(".spar")
.join("state")
.join(format!("review-{number}.json"));
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("no review record at {}: {e}", path.display()));
serde_json::from_str(&text).expect("a review record")
}
fn write_record(fx: &Fixture, number: i64, record: &serde_json::Value) {
let dir = fx.work.join(".spar").join("state");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("review-{number}.json")),
serde_json::to_string_pretty(record).unwrap(),
)
.unwrap();
}
/// `spar review --verdict` with the fake pair, from the work tree. Returns
/// success, stdout, and the gh log.
fn review_run(fx: &Fixture, args: &[&str], a: &str, b: &str, gh: &str) -> (bool, String, String) {
let (config, path) = brainstorm_commands(fx, a, b, gh);
let log = fx.dir.join("gh.log");
let mut argv = vec!["review"];
argv.extend_from_slice(args);
argv.extend_from_slice(&["--config", config.to_str().unwrap(), "--repo"]);
argv.push(fx.work.to_str().unwrap());
let (ok, stdout, stderr) = spar_with_env(
&argv,
&fx.dir,
&[
("PATH", path.as_str()),
("SPAR_FAKE_GH_LOG", log.to_str().unwrap()),
],
);
let log = std::fs::read_to_string(&log).unwrap_or_default();
(ok, format!("{stdout}\n{stderr}"), log)
}
/// `spar review` used to leave nothing behind: not the commit it read, not the
/// points it raised, and never a verdict GitHub would show. A second look needs
/// all three.
#[test]
#[cfg(unix)]
fn review_with_verdict_submits_a_formal_approval_and_leaves_a_record() {
let fx = repo("reviewverdict");
git(&fx.work, &["push", "-q", "origin", "HEAD:refs/pull/5/head"]);
let head = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
let (ok, out, log) = review_run(
&fx,
&["5", "--verdict"],
CLEAN_REVIEW,
CLEAN_REVIEW,
&review_gh(5, &head, "someone-else"),
);
assert!(ok, "{out}");
assert!(
log.contains(&format!(
"{}Two independent reviews, nothing blocking a merge.",
review_post(5, &head, "APPROVE")
)),
"{log}"
);
assert!(!log.contains("pr comment"), "{log}");
assert!(out.contains("#5 clean"), "{out}");
let record = read_record(&fx, 5);
assert_eq!(head, record.head);
assert!(record.posted);
assert_eq!(Some(spar::model::ReviewVerdict::Approve), record.verdict);
assert!(record.findings.is_empty());
assert_eq!(0, record.rechecks);
assert_eq!(20, record.reviewed_at.len(), "{}", record.reviewed_at);
}
#[test]
#[cfg(unix)]
fn review_with_verdict_requests_changes_on_a_blocking_finding_and_records_it() {
let fx = repo("reviewverdictblock");
git(&fx.work, &["push", "-q", "origin", "HEAD:refs/pull/5/head"]);
let head = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
let (ok, out, log) = review_run(
&fx,
&["5", "--verdict"],
ONE_BLOCKER_REVIEW,
ONE_BLOCKER_REVIEW,
&review_gh(5, &head, "someone-else"),
);
assert!(ok, "{out}");
assert!(
log.contains(&format!(
"{}Two independent reviews.",
review_post(5, &head, "REQUEST_CHANGES")
)),
"{log}"
);
assert!(
log.contains("- Retry loop never terminates (src/net.rs:88) [both]."),
"{log}"
);
assert!(out.contains("#5 reviewed"), "{out}");
let record = read_record(&fx, 5);
assert_eq!(
Some(spar::model::ReviewVerdict::RequestChanges),
record.verdict
);
assert_eq!(1, record.findings.len());
assert_eq!("Retry loop never terminates", record.findings[0].title);
assert_eq!(
spar::model::Standing::Corroborated,
record.findings[0].standing
);
assert!(record.findings[0].blocks());
}
/// GitHub refuses an approval from the account that opened the pull request,
/// so the verdict travels as words in a review comment instead.
#[test]
#[cfg(unix)]
fn review_with_verdict_on_your_own_pull_request_posts_a_comment() {
let fx = repo("reviewverdictown");
git(&fx.work, &["push", "-q", "origin", "HEAD:refs/pull/5/head"]);
let head = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
let (ok, out, log) = review_run(
&fx,
&["5", "--verdict"],
CLEAN_REVIEW,
CLEAN_REVIEW,
&review_gh(5, &head, "viewer"),
);
assert!(ok, "{out}");
assert!(
log.contains(&format!(
"{}Verdict: approve.\n\nTwo independent reviews",
review_post(5, &head, "COMMENT")
)),
"{log}"
);
assert!(!log.contains("event=APPROVE"), "{log}");
}
/// Without --verdict nothing changes: a comment, as before, and now a record.
#[test]
#[cfg(unix)]
fn a_plain_review_still_comments_and_now_leaves_a_record() {
let fx = repo("reviewplainrecord");
git(&fx.work, &["push", "-q", "origin", "HEAD:refs/pull/5/head"]);
let head = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
let (ok, out, log) = review_run(
&fx,
&["5"],
CLEAN_REVIEW,
CLEAN_REVIEW,
&review_gh(5, &head, "someone-else"),
);
assert!(ok, "{out}");
assert!(
log.contains("pr comment 5 --body Two independent reviews"),
"{log}"
);
assert!(!submitted_a_review(&log), "{log}");
let record = read_record(&fx, 5);
assert!(record.posted);
assert_eq!(None, record.verdict);
}
const RECHECK_FIXED: &str = r#"{"summary":"holds","rulings":[{"index":1,"outcome":"fixed","reasoning":"line 2 of file.txt now checks the bound"}],"findings":[]}"#;
fn blocking_record(head: &str) -> serde_json::Value {
serde_json::json!({
"version": 1,
"head": head,
"reviewed_at": "2026-01-01T00:00:00Z",
"findings": [{
"title": "Retry loop never terminates",
"file": "src/net.rs:88",
"severity": "blocking",
"detail": "the guard compares against Some(0)",
"in_scope": true,
"raised_by": "a and b",
"standing": "corroborated"
}],
"rechecks": 0,
"verdict": "request_changes",
"posted": true
})
}
/// The whole point: a push after a review gets the earlier points ruled on
/// against the new code, and an approval when both reviewers close them.
#[test]
#[cfg(unix)]
fn a_recheck_rules_on_the_earlier_points_and_approves_when_both_say_fixed() {
let fx = repo("recheckfixed");
let before = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
git(&fx.work, &["push", "-q", "origin", "HEAD:refs/pull/9/head"]);
write_record(&fx, 9, &blocking_record(&before));
commit(&fx.work, "file.txt", "bounded\n", "check the bound");
git(&fx.work, &["push", "-q", "origin", "HEAD:refs/pull/9/head"]);
let after = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
let (ok, out, log) = review_run(
&fx,
&["9", "--recheck"],
RECHECK_FIXED,
RECHECK_FIXED,
&review_gh(9, &after, "someone-else"),
);
assert!(ok, "{out}");
assert!(
log.contains(&format!(
"{}Rechecked after 1 new commit (",
review_post(9, &after, "APPROVE")
)),
"{log}"
);
assert!(log.contains("). Nothing blocking remains."), "{log}");
assert!(
log.contains("addressed\n- Retry loop never terminates (src/net.rs:88). Fixed: line 2 of file.txt now checks the bound"),
"{log}"
);
assert!(out.contains("#9 clean"), "{out}");
let record = read_record(&fx, 9);
assert_eq!(after, record.head);
assert_eq!(1, record.rechecks);
assert!(record.findings.is_empty());
assert_eq!(Some(spar::model::ReviewVerdict::Approve), record.verdict);
assert_ne!("2026-01-01T00:00:00Z", record.reviewed_at);
}
/// One reviewer still seeing the point keeps it open, and changes stay
/// requested with the point named.
#[test]
#[cfg(unix)]
fn a_recheck_keeps_a_point_open_when_one_reviewer_still_sees_it() {
let fx = repo("recheckopen");
let before = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
git(&fx.work, &["push", "-q", "origin", "HEAD:refs/pull/9/head"]);
write_record(&fx, 9, &blocking_record(&before));
commit(&fx.work, "file.txt", "half done\n", "try the bound");
git(&fx.work, &["push", "-q", "origin", "HEAD:refs/pull/9/head"]);
let after = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
let still_open = r#"{"summary":"no","rulings":[{"index":1,"outcome":"open","reasoning":"the guard still compares against Some(0)"}],"findings":[]}"#;
let (ok, out, log) = review_run(
&fx,
&["9", "--recheck"],
RECHECK_FIXED,
still_open,
&review_gh(9, &after, "someone-else"),
);
assert!(ok, "{out}");
assert!(
log.contains(&format!(
"{}Rechecked after 1 new commit (",
review_post(9, &after, "REQUEST_CHANGES")
)),
"{log}"
);
assert!(log.contains("One point still blocks a merge."), "{log}");
assert!(
log.contains("still open\n- Retry loop never terminates (src/net.rs:88) [both]. the guard compares against Some(0). B still sees it: the guard still compares against Some(0)"),
"{log}"
);
assert!(out.contains("#9 reviewed"), "{out}");
assert!(
out.contains("Retry loop never terminates is still open"),
"{out}"
);
let record = read_record(&fx, 9);
assert_eq!(1, record.rechecks);
assert_eq!(1, record.findings.len());
assert_eq!(
Some(spar::model::ReviewVerdict::RequestChanges),
record.verdict
);
}
/// Nothing moved and nobody spoke: no agent runs, nothing is posted, and the
/// run says so.
#[test]
#[cfg(unix)]
fn a_recheck_with_nothing_new_runs_no_agent_and_reports_clean() {
let fx = repo("recheckquiet");
git(&fx.work, &["push", "-q", "origin", "HEAD:refs/pull/9/head"]);
let head = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
let mut record = blocking_record(&head);
record["findings"] = serde_json::json!([]);
write_record(&fx, 9, &record);
let (ok, out, log) = review_run(
&fx,
&["9", "--recheck"],
"exit 1",
"exit 1",
&review_gh(9, &head, "someone-else"),
);
assert!(ok, "{out}");
assert!(out.contains("#9 clean"), "{out}");
assert!(out.contains("nothing changed since the review at"), "{out}");
assert!(!submitted_a_review(&log), "{log}");
assert!(!log.contains("pr comment"), "{log}");
assert_eq!(0, read_record(&fx, 9).rechecks);
}
/// A record left by a dry run has findings nobody has read, so a second look
/// at it would ask the author about points they were never told. A first look
/// happens instead.
#[test]
#[cfg(unix)]
fn a_recheck_without_a_posted_review_on_record_reviews_afresh() {
let fx = repo("recheckunposted");
git(&fx.work, &["push", "-q", "origin", "HEAD:refs/pull/9/head"]);
let head = git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
let mut record = blocking_record(&head);
record["posted"] = serde_json::json!(false);
write_record(&fx, 9, &record);
let (ok, out, log) = review_run(
&fx,
&["9", "--recheck", "--verdict"],
CLEAN_REVIEW,
CLEAN_REVIEW,
&review_gh(9, &head, "someone-else"),
);
assert!(ok, "{out}");
assert!(out.contains("never posted"), "{out}");
assert!(
log.contains(&format!(
"{}Two independent reviews",
review_post(9, &head, "APPROVE")
)),
"{log}"
);
assert!(read_record(&fx, 9).posted);
}
// ---------------------------------------------------------------------------
// Listening
// ---------------------------------------------------------------------------
/// A fake `gh` for `spar listen`. Notifications come from `$SPAR_FAKE_NOTIFY`
/// when that file exists and are a 304 otherwise, the sweep reads
/// `$SPAR_FAKE_PRS`, and pull request `number` is open at `head`, written by
/// `author`, with nothing said on it.
///
/// Switches for the ways GitHub fails or surprises: `$SPAR_FAKE_NOTIFY_PAGE2`
/// is a second page of notifications, and `SPAR_FAKE_PAGE2_FAILS=1` makes it
/// a 500; `SPAR_FAKE_REVIEW_FAILS=1` refuses a submitted review;
/// `SPAR_FAKE_COMMENTS_FAIL=1` fails the read of the pull request's comments;
/// `SPAR_FAKE_ISSUE_STATE` is issue 42's state; `$SPAR_FAKE_LINKED` is the
/// list a linked pull request is looked for in; and `$SPAR_FAKE_REQUESTS` is
/// who the pull request asks for a review, nobody when it is missing. The
/// viewer is in team `ours` and in no other.
fn listen_gh(number: i64, head: &str, author: &str, state: &str) -> String {
format!(
r#"set -eu
printf '%s\n' "$*" >> "$SPAR_FAKE_GH_LOG"
case "$*" in
*notifications?page=2*)
if [ "${{SPAR_FAKE_PAGE2_FAILS:-}}" = 1 ]; then
printf 'HTTP/2.0 500 Internal Server Error\r\nContent-Type: application/json\r\n\r\n{{"message":"Server Error"}}\n'
else
printf 'HTTP/2.0 200 OK\r\nContent-Type: application/json\r\n\r\n'
cat "$SPAR_FAKE_NOTIFY_PAGE2"
fi ;;
*notifications*)
if [ -f "${{SPAR_FAKE_NOTIFY:-/nonexistent}}" ]; then
printf 'HTTP/2.0 200 OK\r\nX-Poll-Interval: 60\r\nLast-Modified: Tue, 20 Oct 2026 10:00:00 GMT\r\n'
if [ -f "${{SPAR_FAKE_NOTIFY_PAGE2:-/nonexistent}}" ]; then
printf 'Link: <https://api.github.com/repos/example/project/notifications?page=2>; rel="next"\r\n'
fi
printf 'Content-Type: application/json\r\n\r\n'
cat "$SPAR_FAKE_NOTIFY"
elif [ "${{SPAR_FAKE_POLL_FAILS:-}}" = 1 ]; then
printf 'gh: API rate limit exceeded for viewer\n' >&2
exit 1
else
printf 'HTTP/2.0 304 Not Modified\r\nX-Poll-Interval: 60\r\n\r\n'
exit 1
fi ;;
"api user --jq .login") printf 'viewer\n' ;;
"api -i orgs/example/teams/ours/memberships/viewer")
printf 'HTTP/2.0 200 OK\r\nContent-Type: application/json\r\n\r\n{{"state":"active","role":"member"}}\n' ;;
"api -i orgs/"*)
printf 'HTTP/2.0 404 Not Found\r\nContent-Type: application/json\r\n\r\n{{"message":"Not Found"}}\n'
exit 1 ;;
"repo view --json nameWithOwner --jq .nameWithOwner") printf 'example/project\n' ;;
"pr list --state open --limit 500 --json number,title,headRefOid,"*) cat "$SPAR_FAKE_PRS" ;;
"pr list --state open --limit 500 --json number,url,title,baseRefName,closingIssuesReferences")
if [ -f "${{SPAR_FAKE_LINKED:-/nonexistent}}" ]; then cat "$SPAR_FAKE_LINKED"; else printf '[]\n'; fi ;;
"pr list "*) printf '[]\n' ;;
"pr view {number} --json number,title,headRefOid,"*)
requests='[]'
if [ -f "${{SPAR_FAKE_REQUESTS:-/nonexistent}}" ]; then requests=$(cat "$SPAR_FAKE_REQUESTS"); fi
printf '{{"number":{number},"title":"PR {number}","headRefOid":"{head}","updatedAt":"2026-10-20T10:00:00Z","author":{{"login":"{author}"}},"isDraft":false,"isCrossRepository":false,"reviewRequests":%s,"assignees":[],"state":"{state}"}}\n' "$requests" ;;
"pr view {number} --json author") printf '%s\n' '{{"author":{{"login":"{author}"}}}}' ;;
"pr view {number} --json headRefOid") printf '%s\n' '{{"headRefOid":"{head}"}}' ;;
"pr view {number} "*) printf '%s\n' '{{"number":{number},"url":"https://github.com/example/project/pull/{number}","title":"PR {number}","body":"","headRefName":"feature-{number}","baseRefName":"main","state":"{state}","closingIssuesReferences":[],"isCrossRepository":false}}' ;;
"api repos/{{owner}}/{{repo}}/issues/{number} --jq"*) printf 'pr\n' ;;
"api repos/{{owner}}/{{repo}}/issues/42 --jq"*) printf 'issue\n' ;;
"issue view 42 "*) printf '{{"number":42,"title":"Retry smarter","body":"please","labels":[],"state":"%s","url":"https://github.com/example/project/issues/42","author":{{"login":"bob"}},"comments":[]}}\n' "${{SPAR_FAKE_ISSUE_STATE:-OPEN}}" ;;
"api repos/{{owner}}/{{repo}}/compare/"*) printf '3\n' ;;
"api --paginate repos/{{owner}}/{{repo}}/issues/{number}/comments")
if [ "${{SPAR_FAKE_COMMENTS_FAIL:-}}" = 1 ]; then printf 'gh: HTTP 502: Bad Gateway\n' >&2; exit 1; fi
printf '[]\n' ;;
"api --paginate "*) printf '[]\n' ;;
"api graphql "*) printf '%s\n' '{{"data":{{"repository":{{"pullRequest":{{"reviewThreads":{{"pageInfo":{{"hasNextPage":false,"endCursor":null}},"nodes":[]}}}}}}}}}}' ;;
"api -X POST repos/{{owner}}/{{repo}}/pulls/{number}/reviews "*)
if [ "${{SPAR_FAKE_REVIEW_FAILS:-}}" = 1 ]; then printf 'gh: Unprocessable Entity (HTTP 422)\n' >&2; exit 1; fi ;;
"pr comment "*) : ;;
*) printf 'unexpected gh call: %s\n' "$*" >&2; exit 64 ;;
esac"#
)
}
/// An agent that notes it was called and refuses, so a listener that should
/// have spent nothing fails loudly if it did.
const REFUSING_AGENT: &str = "printf 'called\\n' >> \"$SPAR_FAKE_AGENT_LOG\"; exit 1";
fn sweep_row(number: i64, head: &str, author: &str) -> String {
format!(
r#"{{"number":{number},"title":"PR {number}","headRefOid":"{head}","updatedAt":"2026-10-20T10:00:00Z","author":{{"login":"{author}"}},"isDraft":false,"isCrossRepository":false,"reviewRequests":[],"assignees":[],"state":"OPEN"}}"#
)
}
fn notification(id: &str, reason: &str, kind: &str, number: i64) -> String {
let path = if kind == "PullRequest" {
"pulls"
} else {
"issues"
};
format!(
r#"{{"id":"{id}","unread":true,"reason":"{reason}","updated_at":"2026-10-20T10:0{id}:00Z","subject":{{"title":"thing {number}","url":"https://api.github.com/repos/example/project/{path}/{number}","latest_comment_url":null,"type":"{kind}"}}}}"#
)
}
struct Listening {
fx: Fixture,
config: PathBuf,
path: String,
gh_log: PathBuf,
agent_log: PathBuf,
notify: PathBuf,
prs: PathBuf,
requests: PathBuf,
}
impl Listening {
/// A fixture with the fake pair, a fake gh, and no notifications yet.
fn new(tag: &str, a: &str, b: &str, gh: &str) -> Self {
let fx = repo(tag);
let a = format!("printf '%s\\n' >> \"$SPAR_FAKE_AGENT_LOG\" called; printf '%s\\n' '{a}'");
let b = format!("printf '%s\\n' >> \"$SPAR_FAKE_AGENT_LOG\" called; printf '%s\\n' '{b}'");
let (config, path) = brainstorm_commands(&fx, "", "", gh);
// The fixture's agents print a fixed answer; these also leave a mark
// per call, which is how a test proves nothing ran.
let bin = fx.dir.join("fake-bin");
executable(&bin.join("agent-a"), &format!("#!/bin/sh\n{a}\n"));
executable(&bin.join("agent-b"), &format!("#!/bin/sh\n{b}\n"));
let prs = fx.dir.join("prs.json");
std::fs::write(&prs, "[]\n").unwrap();
Self {
gh_log: fx.dir.join("gh.log"),
agent_log: fx.dir.join("agent.log"),
notify: fx.dir.join("notify.json"),
requests: fx.dir.join("requests.json"),
prs,
fx,
config,
path,
}
}
fn refusing(tag: &str, gh: &str) -> Self {
let this = Self::new(tag, "", "", gh);
let bin = this.fx.dir.join("fake-bin");
executable(
&bin.join("agent-a"),
&format!("#!/bin/sh\n{REFUSING_AGENT}\n"),
);
executable(
&bin.join("agent-b"),
&format!("#!/bin/sh\n{REFUSING_AGENT}\n"),
);
this
}
fn with_prs(&self, rows: &[String]) {
std::fs::write(&self.prs, format!("[{}]\n", rows.join(","))).unwrap();
}
fn with_notifications(&self, threads: &[String]) {
std::fs::write(&self.notify, format!("[{}]\n", threads.join(","))).unwrap();
}
fn with_gh(&self, gh: &str) {
executable(
&self.fx.dir.join("fake-bin").join("gh"),
&format!("#!/bin/sh\n{gh}\n"),
);
}
/// Publish the work tree's HEAD as the head of pull request `number` the
/// way GitHub has it once it has caught up with a push: `refs/pull/N/head`,
/// the fake gh's `headRefOid` and the sweep all name the same commit. A
/// review reads a head only when they agree.
fn publish_head(&self, number: i64, author: &str) -> String {
git(
&self.fx.work,
&[
"push",
"-q",
"origin",
&format!("HEAD:refs/pull/{number}/head"),
],
);
let head = work_head(self);
self.with_gh(&listen_gh(number, &head, author, "OPEN"));
self.with_prs(&[sweep_row(number, &head, author)]);
head
}
/// Who the pull request asks for a review when it is read, as gh gives
/// `reviewRequests`.
fn with_review_requests(&self, requests: &str) {
std::fs::write(&self.requests, format!("{requests}\n")).unwrap();
}
/// The viewer, by name: what a review request just made looks like.
fn asking_the_viewer(&self) {
self.with_review_requests(r#"[{"__typename":"User","login":"viewer"}]"#);
}
fn env(&self) -> Vec<(String, String)> {
vec![
("PATH".into(), self.path.clone()),
("NO_COLOR".into(), "1".into()),
("SPAR_FAKE_GH_LOG".into(), self.gh_log.display().to_string()),
(
"SPAR_FAKE_AGENT_LOG".into(),
self.agent_log.display().to_string(),
),
("SPAR_FAKE_NOTIFY".into(), self.notify.display().to_string()),
("SPAR_FAKE_PRS".into(), self.prs.display().to_string()),
(
"SPAR_FAKE_REQUESTS".into(),
self.requests.display().to_string(),
),
]
}
/// `spar listen <args>`: success, stdout and stderr together, the gh log.
fn run(&self, args: &[&str]) -> (bool, String, String) {
self.run_with(args, &[])
}
/// The same, with more of the environment set.
fn run_with(&self, args: &[&str], extra: &[(&str, &str)]) -> (bool, String, String) {
let mut argv = vec!["listen"];
argv.extend_from_slice(args);
argv.extend_from_slice(&["--config", self.config.to_str().unwrap(), "--repo"]);
argv.push(self.fx.work.to_str().unwrap());
let env = self.env();
let mut env: Vec<(&str, &str)> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
env.extend_from_slice(extra);
let (ok, stdout, stderr) = spar_with_env(&argv, &self.fx.dir, &env);
let log = std::fs::read_to_string(&self.gh_log).unwrap_or_default();
(ok, format!("{stdout}\n{stderr}"), log)
}
/// Add to the config, a table at a time.
fn configure(&self, toml: &str) {
let mut config = std::fs::read_to_string(&self.config).unwrap();
config.push('\n');
config.push_str(toml);
std::fs::write(&self.config, config).unwrap();
}
fn listen_json_exists(&self) -> bool {
self.fx.work.join(".spar/state/listen.json").exists()
}
fn agent_calls(&self) -> usize {
std::fs::read_to_string(&self.agent_log)
.unwrap_or_default()
.lines()
.count()
}
fn state(&self) -> serde_json::Value {
let text = std::fs::read_to_string(self.fx.work.join(".spar/state/listen.json"))
.expect("listen.json");
serde_json::from_str(&text).expect("listen state")
}
fn lock_exists(&self) -> bool {
self.fx.work.join(".spar/state/listen.lock").exists()
}
}
/// The promise the command rests on: waiting spends nothing. A poll that finds
/// nothing is a 304, which gh reports as a failure, and spar reads as quiet.
#[test]
#[cfg(unix)]
fn listen_once_with_nothing_new_calls_no_agent_and_writes_nothing() {
let l = Listening::refusing("listenquiet", &listen_gh(7, "aaa", "alice", "OPEN"));
let (ok, out, log) = l.run(&["--once"]);
assert!(ok, "{out}");
assert!(out.contains("listening to example/project"), "{out}");
assert!(
out.contains("polling every 60s, sweeping open pull requests every 300s"),
"{out}"
);
assert_eq!(0, l.agent_calls(), "{out}");
assert!(!submitted_a_review(&log), "{log}");
assert!(!log.contains("pr comment"), "{log}");
assert!(log.contains("api -i repos/{owner}/{repo}/notifications?participating=true&all=true&per_page=100&since="), "{log}");
let state = l.state();
assert_eq!(
20,
state["watermark"].as_str().unwrap_or("").len(),
"{state}"
);
assert!(!l.lock_exists());
}
/// Every event and what it would trigger, and nothing triggered: the way to
/// see what listen would do before letting it. Nothing consumed either: a dry
/// run used to write listen.json, and the real run after it found nothing to
/// do for anything the dry run had shown.
#[test]
#[cfg(unix)]
fn listen_once_dry_run_names_what_each_event_would_trigger_and_triggers_nothing() {
let l = Listening::new(
"listendry",
ONE_BLOCKER_REVIEW,
ONE_BLOCKER_REVIEW,
&listen_gh(7, "aaa", "alice", "OPEN"),
);
l.publish_head(7, "alice");
l.with_notifications(&[
notification("1", "review_requested", "PullRequest", 7),
notification("2", "mention", "Issue", 42),
]);
l.asking_the_viewer();
let (ok, out, _) = l.run(&["--once", "--dry-run"]);
assert!(ok, "{out}");
assert!(
out.contains("#7: news on a thread that asked for your review"),
"{out}"
);
assert!(
out.contains("#7: would review it after a review request, dry run so nothing runs"),
"{out}"
);
assert!(out.contains("#42: mentioned"), "{out}");
assert!(
out.contains("#42: ignoring an issue with no reviewed pull request"),
"{out}"
);
assert_eq!(0, l.agent_calls(), "{out}");
assert!(!l.listen_json_exists(), "a dry run wrote listen.json");
// What the dry run showed is what the real run does.
let (ok, out, log) = l.run(&["--once"]);
assert!(ok, "{out}");
assert!(
out.contains("#7: about to review it after a review request"),
"{out}"
);
assert!(submitted_a_review(&log), "{log}");
assert_eq!(2, l.agent_calls(), "{out}");
}
/// A push raises no notification for a reviewer, so the sweep is what notices
/// a reviewed pull request moving. A dry run keeps nothing, so a second one
/// sees the same push; a real run is what moves past it.
#[test]
#[cfg(unix)]
fn listen_once_dry_run_notices_a_reviewed_pull_request_whose_head_moved() {
let l = Listening::refusing("listenmoved", &listen_gh(108, "bbb", "alice", "OPEN"));
write_record(&l.fx, 108, &blocking_record("aaa"));
l.with_prs(&[sweep_row(108, "bbb", "alice")]);
for _ in 0..2 {
let (ok, out, _) = l.run(&["--once", "--dry-run"]);
assert!(ok, "{out}");
assert!(
out.contains("#108: head moved aaa to bbb, 3 new commits since the review"),
"{out}"
);
assert!(
out.contains("#108: would take a second look after a push"),
"{out}"
);
}
assert_eq!(0, l.agent_calls());
assert!(!l.listen_json_exists());
}
/// The first real dispatch: a review request becomes a review with a verdict.
#[test]
#[cfg(unix)]
fn listen_once_with_a_review_request_reviews_the_pr_with_a_verdict() {
let l = Listening::new(
"listenreview",
ONE_BLOCKER_REVIEW,
ONE_BLOCKER_REVIEW,
&listen_gh(7, "aaa", "alice", "OPEN"),
);
let head = l.publish_head(7, "alice");
l.with_notifications(&[notification("1", "review_requested", "PullRequest", 7)]);
l.asking_the_viewer();
let (ok, out, log) = l.run(&["--once"]);
assert!(ok, "{out}");
assert!(
out.contains("#7: about to review it after a review request"),
"{out}"
);
assert!(
log.contains(&format!(
"{}Two independent reviews.",
review_post(7, &head, "REQUEST_CHANGES")
)),
"{log}"
);
assert_eq!(2, l.agent_calls(), "{out}");
assert!(out.contains("#7 reviewed"), "{out}");
assert!(out.contains("1 dispatch"), "{out}");
assert!(read_record(&l.fx, 7).posted);
}
/// react-native-pubky#45: spar had reviewed the pull request at its head, and
/// its own review moved a thread that began as a review request. GitHub kept
/// the old reason, the listener took it for a new request, and it posted a
/// note nobody had asked for. What the pull request says now decides: a
/// request still open, to the viewer or to a team the viewer is in, gets the
/// note, and one already answered, or one to a team the viewer is not in, gets
/// a quiet second look.
#[test]
#[cfg(unix)]
fn a_review_request_gets_the_standing_note_only_while_it_is_open() {
let cases = [
(
"listenaskedname",
r#"[{"__typename":"User","login":"viewer"}]"#,
true,
),
(
"listenaskedteam",
r#"[{"__typename":"Team","name":"Ours","slug":"example/ours"}]"#,
true,
),
(
"listenotherteam",
r#"[{"__typename":"Team","name":"Theirs","slug":"example/theirs"}]"#,
false,
),
("listenanswered", "[]", false),
];
for (tag, requests, open) in cases {
let l = Listening::refusing(tag, &listen_gh(7, "aaa", "alice", "OPEN"));
write_record(&l.fx, 7, &blocking_record("aaa"));
l.with_prs(&[sweep_row(7, "aaa", "alice")]);
l.with_notifications(&[notification("1", "review_requested", "PullRequest", 7)]);
l.with_review_requests(requests);
let (ok, out, log) = l.run(&["--once"]);
assert!(ok, "{tag}: {out}");
assert_eq!(0, l.agent_calls(), "{tag}: {out}");
assert!(!submitted_a_review(&log), "{tag}: {log}");
assert_eq!(
open,
log.contains("pr comment 7 --body Nothing has changed since the review at aaa"),
"{tag}: {log}"
);
if open {
assert!(
out.contains("#7: about to take a second look after a review request"),
"{tag}: {out}"
);
} else {
assert!(
out.contains("#7: no review is asked of you now, so that was only an update"),
"{tag}: {out}"
);
assert!(
out.contains("#7: about to take a second look after a reply"),
"{tag}: {out}"
);
}
}
}
/// A standing note is a comment, so the request it answered stays open. A
/// sweep that had last seen the pull request with no request took the open
/// one for the author asking again, and answered it a second time.
#[test]
#[cfg(unix)]
fn a_request_answered_with_the_standing_note_is_not_raised_again_by_the_sweep() {
let l = Listening::refusing("listennoteonce", &listen_gh(7, "aaa", "alice", "OPEN"));
write_record(&l.fx, 7, &blocking_record("aaa"));
// The sweep reads the pull request as it was before the author asked.
l.with_prs(&[sweep_row(7, "aaa", "alice")]);
l.with_notifications(&[notification("1", "review_requested", "PullRequest", 7)]);
l.asking_the_viewer();
let (ok, out, log) = l.run(&["--once"]);
assert!(ok, "{out}");
assert_eq!(1, log.matches("pr comment 7 ").count(), "{log}");
// The next sweep finds the request still open, which is not news.
let asked = sweep_row(7, "aaa", "alice").replace(
r#""reviewRequests":[]"#,
r#""reviewRequests":[{"login":"viewer"}]"#,
);
l.with_prs(&[asked]);
std::fs::remove_file(&l.notify).unwrap();
let (ok, out, log) = l.run(&["--once"]);
assert!(ok, "{out}");
assert!(!out.contains("#7: your review was requested"), "{out}");
assert!(!out.contains("about to take a second look"), "{out}");
assert_eq!(1, log.matches("pr comment 7 ").count(), "{log}");
}
/// The whole point, end to end: the author pushed after a review, the listener
/// noticed, both reviewers closed the point, and the pull request is approved.
#[test]
#[cfg(unix)]
fn listen_once_rechecks_a_reviewed_pull_request_that_moved_and_approves() {
let fx_head = |fx: &Fixture| git(&fx.work, &["rev-parse", "HEAD"]).trim().to_string();
let l = {
// The gh script needs the final head, so the commits come first.
let fx = repo("listenrecheck");
let before = fx_head(&fx);
git(&fx.work, &["push", "-q", "origin", "HEAD:refs/pull/7/head"]);
commit(&fx.work, "file.txt", "bounded\n", "check the bound");
git(&fx.work, &["push", "-q", "origin", "HEAD:refs/pull/7/head"]);
let after = fx_head(&fx);
write_record(&fx, 7, &blocking_record(&before));
drop(fx);
let l = Listening::new(
"listenrecheck",
RECHECK_FIXED,
RECHECK_FIXED,
&listen_gh(7, &after, "alice", "OPEN"),
);
// A second fixture with the same tag is a different directory, so
// redo the history in it.
git(
&l.fx.work,
&["push", "-q", "origin", "HEAD:refs/pull/7/head"],
);
let before = fx_head(&l.fx);
commit(&l.fx.work, "file.txt", "bounded\n", "check the bound");
git(
&l.fx.work,
&["push", "-q", "origin", "HEAD:refs/pull/7/head"],
);
let after = fx_head(&l.fx);
write_record(&l.fx, 7, &blocking_record(&before));
let bin = l.fx.dir.join("fake-bin");
executable(
&bin.join("gh"),
&format!("#!/bin/sh\n{}\n", listen_gh(7, &after, "alice", "OPEN")),
);
l.with_prs(&[sweep_row(7, &after, "alice")]);
l
};
let (ok, out, log) = l.run(&["--once"]);
assert!(ok, "{out}");
assert!(out.contains("#7: head moved"), "{out}");
assert!(
out.contains("#7: about to take a second look after a push"),
"{out}"
);
let head = git(&l.fx.work, &["rev-parse", "HEAD"]).trim().to_string();
assert!(
log.contains(&format!(
"{}Rechecked after 1 new commit (",
review_post(7, &head, "APPROVE")
)),
"{log}"
);
let record = read_record(&l.fx, 7);
assert_eq!(1, record.rechecks);
assert!(record.findings.is_empty());
assert_eq!(2, l.agent_calls(), "{out}");
// The commit spar just read is not news to the next sweep.
let (ok, out, _) = l.run(&["--once"]);
assert!(ok, "{out}");
assert!(!out.contains("head moved"), "{out}");
assert_eq!(2, l.agent_calls(), "{out}");
}
fn work_head(l: &Listening) -> String {
git(&l.fx.work, &["rev-parse", "HEAD"]).trim().to_string()
}
/// A reply woke the listener while GitHub still named the commit before a
/// push, so the second look read that commit, called it no new commit, and
/// asked for changes the branch already had. It now waits for the pull request
/// to catch up with its branch, then reads the push.
#[test]
#[cfg(unix)]
fn listen_once_waits_for_github_to_catch_up_with_a_push_before_a_second_look() {
let l = Listening::new(
"listenlag",
RECHECK_FIXED,
RECHECK_FIXED,
&listen_gh(7, "aaa", "alice", "OPEN"),
);
let reviewed = work_head(&l);
git(
&l.fx.work,
&["push", "-q", "origin", "HEAD:refs/pull/7/head"],
);
write_record(&l.fx, 7, &blocking_record(&reviewed));
// The branch moved, and the pull request has not caught up with it.
commit(&l.fx.work, "file.txt", "bounded\n", "check the bound");
git(
&l.fx.work,
&["push", "-q", "origin", "HEAD:refs/heads/feature-7"],
);
let pushed = work_head(&l);
l.with_gh(&listen_gh(7, &reviewed, "alice", "OPEN"));
l.with_prs(&[sweep_row(7, &reviewed, "alice")]);
l.with_notifications(&[notification("1", "comment", "PullRequest", 7)]);
let (ok, out, log) = l.run(&["--once"]);
assert!(ok, "{out}");
assert!(
out.contains(&format!(
"GitHub has not caught up with the push to feature-7 yet: the pull request says {}, \
the branch is at {}",
&reviewed[..7],
&pushed[..7]
)),
"{out}"
);
assert!(
out.contains("#7: its head moved while spar read it, so it runs again on the new head"),
"{out}"
);
assert_eq!(0, l.agent_calls(), "{out}");
assert!(!submitted_a_review(&log), "{log}");
assert!(!log.contains("pr comment"), "{log}");
let record = read_record(&l.fx, 7);
assert_eq!(reviewed, record.head);
assert_eq!(0, record.rechecks);
assert!(l.state()["pending"].get("7").is_some(), "{}", l.state());
// GitHub catches up, and the wait the listener set has passed.
git(
&l.fx.work,
&["push", "-q", "origin", "HEAD:refs/pull/7/head"],
);
l.with_gh(&listen_gh(7, &pushed, "alice", "OPEN"));
l.with_prs(&[sweep_row(7, &pushed, "alice")]);
l.with_notifications(&[]);
let mut state = l.state();
state["items"]["7"]["retry_after"] = 0.into();
std::fs::write(
l.fx.work.join(".spar/state/listen.json"),
serde_json::to_string(&state).unwrap(),
)
.unwrap();
let (ok, out, log) = l.run(&["--once"]);
assert!(ok, "{out}");
assert!(
log.contains(&format!(
"{}Rechecked after 1 new commit (",
review_post(7, &pushed, "APPROVE")
)),
"{log}"
);
let record = read_record(&l.fx, 7);
assert_eq!(pushed, record.head);
assert_eq!(1, record.rechecks);
assert_eq!(0, l.state()["items"]["7"]["failures"], "{}", l.state());
}
/// A first review that GitHub's lag behind a push stopped until the listener
/// gave up used to be lost: once GitHub caught up, the push was all that came
/// back, and a push to a pull request spar has not reviewed is ignored. The
/// request is held now, and the push brings it back.
#[test]
#[cfg(unix)]
fn a_requested_review_given_up_on_while_github_lagged_runs_once_the_push_shows() {
let l = Listening::new(
"listenlaggaveup",
ONE_BLOCKER_REVIEW,
ONE_BLOCKER_REVIEW,
&listen_gh(7, "aaa", "alice", "OPEN"),
);
let asked = work_head(&l);
git(
&l.fx.work,
&["push", "-q", "origin", "HEAD:refs/pull/7/head"],
);
commit(&l.fx.work, "file.txt", "bounded\n", "check the bound");
git(
&l.fx.work,
&["push", "-q", "origin", "HEAD:refs/heads/feature-7"],
);
let pushed = work_head(&l);
l.with_gh(&listen_gh(7, &asked, "alice", "OPEN"));
l.with_prs(&[sweep_row(7, &asked, "alice")]);
l.with_notifications(&[notification("1", "review_requested", "PullRequest", 7)]);
l.asking_the_viewer();
let edit_state = |edit: &dyn Fn(&mut serde_json::Value)| {
let mut state = l.state();
edit(&mut state);
std::fs::write(
l.fx.work.join(".spar/state/listen.json"),
serde_json::to_string(&state).unwrap(),
)
.unwrap();
};
let (ok, out, _) = l.run(&["--once"]);
assert!(ok, "{out}");
assert!(
out.contains("#7: its head moved while spar read it, so it runs again on the new head"),
"{out}"
);
// GitHub stays behind for as many looks as the listener allows.
edit_state(&|state| {
state["items"]["7"]["failures"] = 4.into();
state["items"]["7"]["retry_after"] = 0.into();
});
let (ok, out, _) = l.run(&["--once"]);
assert!(!ok, "{out}");
assert!(
out.contains("#7: giving up on it until it changes again"),
"{out}"
);
let state = l.state();
assert!(state["pending"].get("7").is_none(), "{state}");
assert_eq!(
serde_json::json!(["review_requested"]),
state["items"]["7"]["held"],
"{state}"
);
// GitHub catches up, which the sweep sees as a push.
git(
&l.fx.work,
&["push", "-q", "origin", "HEAD:refs/pull/7/head"],
);
l.with_gh(&listen_gh(7, &pushed, "alice", "OPEN"));
l.with_prs(&[sweep_row(7, &pushed, "alice")]);
l.with_notifications(&[]);
edit_state(&|state| state["items"]["7"]["retry_after"] = 0.into());
let (ok, out, log) = l.run(&["--once"]);
assert!(ok, "{out}");
assert!(
out.contains("#7: about to review it after a push and a review request"),
"{out}"
);
assert!(
log.contains(&format!(
"{}Two independent reviews.",
review_post(7, &pushed, "REQUEST_CHANGES")
)),
"{log}"
);
assert_eq!(2, l.agent_calls(), "{out}");
let state = l.state();
assert_eq!(0, state["items"]["7"]["failures"], "{state}");
assert_eq!(
serde_json::json!([]),
state["items"]["7"]["held"],
"{state}"
);
}
/// A look takes minutes, and a push that lands while it runs used to leave its
/// verdict on a commit the author had moved past. A look whose head moved by
/// the time it is ready to post keeps it off the pull request, and the next
/// look reads the new head.
#[test]
#[cfg(unix)]
fn listen_once_does_not_post_a_second_look_whose_head_moved_while_it_ran() {
let l = Listening::new(
"listenmovedunder",
RECHECK_FIXED,
RECHECK_FIXED,
&listen_gh(7, "aaa", "alice", "OPEN"),
);
let reviewed = work_head(&l);
git(
&l.fx.work,
&["push", "-q", "origin", "HEAD:refs/pull/7/head"],
);
write_record(&l.fx, 7, &blocking_record(&reviewed));
commit(&l.fx.work, "file.txt", "bounded\n", "check the bound");
git(
&l.fx.work,
&["push", "-q", "origin", "HEAD:refs/pull/7/head"],
);
let read = work_head(&l);
// The first read of the head is the one the look reads. Every read after
// it finds a push that landed while the agents ran.
let later = "c49363528b6fc025465828bc7115ba3a4541a7b6";
let calls = l.fx.dir.join("head-calls");
l.with_gh(&format!(
"if [ \"$*\" = \"pr view 7 --json headRefOid\" ]; then\n n=$(cat '{calls}' 2>/dev/null \
|| printf 0)\n n=$((n + 1))\n printf '%s' \"$n\" > '{calls}'\n if [ \"$n\" -gt 1 ]; \
then printf '%s\\n' '{{\"headRefOid\":\"{later}\"}}'; exit 0; fi\nfi\n{}",
listen_gh(7, &read, "alice", "OPEN"),
calls = calls.display(),
));
l.with_prs(&[sweep_row(7, &read, "alice")]);
let (ok, out, log) = l.run(&["--once"]);
assert!(ok, "{out}");
assert_eq!(2, l.agent_calls(), "{out}");
assert!(
out.contains(&format!(
"the head moved from {} to c493635 while it was being read, so the review of {} was \
not posted",
&read[..7],
&read[..7]
)),
"{out}"
);
assert!(out.contains("spar post 7"), "{out}");
assert!(
out.contains("#7: its head moved while spar read it, so it runs again on the new head"),
"{out}"
);
assert!(!submitted_a_review(&log), "{log}");
let record = read_record(&l.fx, 7);
assert_eq!(reviewed, record.head);
assert_eq!(0, record.rechecks);
assert!(l.fx.work.join(".spar/reviews/pr-7.md").exists());
assert!(l.state()["pending"].get("7").is_some(), "{}", l.state());
}
/// A lock file is not a lock: one left behind by a listener that died, even
/// naming a pid that is alive now, as a reboot can arrange, holds nothing.
#[test]
#[cfg(unix)]
fn listen_takes_over_a_lock_file_nobody_holds() {
let l = Listening::refusing("listenlock", &listen_gh(7, "aaa", "alice", "OPEN"));
let dir = l.fx.work.join(".spar/state");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("listen.lock"),
format!(
r#"{{"pid":{},"started":"2026-10-20T10:00:00Z"}}"#,
std::process::id()
),
)
.unwrap();
let (ok, out, _) = l.run(&["--once"]);
assert!(ok, "{out}");
assert!(!out.contains("already watching"), "{out}");
assert!(!l.lock_exists());
}
/// A listener started with `settle = 0` and `listen`, whose agents sleep, so
/// it is in the middle of reviewing #7 for as long as the test needs. Returns
/// the child and the agent's pid.
#[cfg(unix)]
fn busy_listener(l: &Listening, listen: &str) -> (std::process::Child, u32) {
l.publish_head(7, "alice");
let pid_file = l.fx.dir.join("agent.pid");
let sleeper = format!(
"#!/bin/sh\necho $$ > \"{}\"\nsleep 300\n",
pid_file.display()
);
let bin = l.fx.dir.join("fake-bin");
executable(&bin.join("agent-a"), &sleeper);
executable(&bin.join("agent-b"), &sleeper);
l.configure(&format!("[listen]\nsettle = 0\n{listen}"));
l.with_notifications(&[notification("1", "review_requested", "PullRequest", 7)]);
l.asking_the_viewer();
let mut command = Command::new(SPAR_BIN);
command
.args([
"listen",
"--config",
l.config.to_str().unwrap(),
"--repo",
l.fx.work.to_str().unwrap(),
])
.current_dir(&l.fx.dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
for (k, v) in &l.env() {
command.env(k, v);
}
let mut child = command.spawn().expect("spar listen starts");
let started = std::time::Instant::now();
while !pid_file.exists() {
if started.elapsed() > std::time::Duration::from_secs(20) {
let _ = child.kill();
panic!("the agent never started");
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
std::thread::sleep(std::time::Duration::from_millis(200));
let agent: u32 = std::fs::read_to_string(&pid_file)
.unwrap()
.trim()
.parse()
.unwrap();
(child, agent)
}
#[cfg(unix)]
fn alive(pid: u32) -> bool {
Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.unwrap()
.success()
}
/// The lock used to count as stale once its owner had not touched it for a
/// while, and the owner touched it once a tick, never during a dispatch. A
/// review longer than fifteen minutes let a second listener in, which reviewed
/// the same pull request in the same worktree and deleted the first one's lock
/// on its way out. Held is now held, for as long as the listener lives.
#[test]
#[cfg(unix)]
fn a_second_listener_is_refused_while_the_first_is_in_the_middle_of_a_review() {
let l = Listening::refusing("listenbusy", &listen_gh(7, "aaa", "alice", "OPEN"));
// Short timings, so the old rule's fifteen minutes is three seconds, and
// a wait past them.
let (mut first, agent) = busy_listener(&l, "interval = 1\nsweep = 1\n");
std::thread::sleep(std::time::Duration::from_secs(4));
let (ok, out, _) = l.run(&["--once", "--dry-run"]);
let refused_while_alive = alive(agent);
let lock_kept = l.lock_exists();
let _ = Command::new("kill")
.args(["-INT", &first.id().to_string()])
.status();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
while first.try_wait().unwrap().is_none() {
if std::time::Instant::now() > deadline {
let _ = first.kill();
panic!("the first listener did not stop on Ctrl-C");
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
assert!(!ok, "{out}");
assert!(out.contains("already watching this checkout"), "{out}");
assert!(out.contains(&format!("pid {}", first.id())), "{out}");
assert!(!out.contains("would review it"), "{out}");
assert!(refused_while_alive, "the first listener's agent had gone");
assert!(
lock_kept,
"the refused listener removed the first one's lock"
);
assert!(!l.lock_exists(), "the first listener left its lock behind");
}
/// A pull request whose posted review at one commit is followed by a push,
/// with the fake gh reporting the new head. Returns the listener and the head
/// after the push.
#[cfg(unix)]
fn moved_since_its_review(tag: &str, a: &str, b: &str) -> (Listening, String) {
let l = Listening::new(tag, a, b, &listen_gh(7, "unknown", "alice", "OPEN"));
git(
&l.fx.work,
&["push", "-q", "origin", "HEAD:refs/pull/7/head"],
);
let before = git(&l.fx.work, &["rev-parse", "HEAD"]).trim().to_string();
write_record(&l.fx, 7, &blocking_record(&before));
commit(&l.fx.work, "file.txt", "bounded\n", "check the bound");
git(
&l.fx.work,
&["push", "-q", "origin", "HEAD:refs/pull/7/head"],
);
let after = git(&l.fx.work, &["rev-parse", "HEAD"]).trim().to_string();
executable(
&l.fx.dir.join("fake-bin").join("gh"),
&format!("#!/bin/sh\n{}\n", listen_gh(7, &after, "alice", "OPEN")),
);
l.with_prs(&[sweep_row(7, &after, "alice")]);
(l, after)
}
/// A review GitHub refused has not happened. The listener used to clear its
/// trigger anyway, because the run reported its findings as reviewed, and the
/// review request was never acted on again.
#[test]
#[cfg(unix)]
fn listen_keeps_a_review_it_could_not_post_for_another_try() {
let l = Listening::new(
"listenunposted",
ONE_BLOCKER_REVIEW,
ONE_BLOCKER_REVIEW,
&listen_gh(7, "aaa", "alice", "OPEN"),
);
l.publish_head(7, "alice");
l.with_notifications(&[notification("1", "review_requested", "PullRequest", 7)]);
l.asking_the_viewer();
let (ok, out, _) = l.run_with(&["--once"], &[("SPAR_FAKE_REVIEW_FAILS", "1")]);
assert!(!ok, "{out}");
assert!(
out.contains("#7: not everything it wrote reached GitHub, so it runs again in 15m00s"),
"{out}"
);
let state = l.state();
assert!(state["pending"]["7"].is_object(), "{state}");
assert_eq!(1, state["items"]["7"]["failures"], "{state}");
assert!(!read_record(&l.fx, 7).posted);
}
/// A second look that could not post left the old record in place, and the
/// listener took that record's commit for the head, so every sweep raised the
/// same push again and ran both reviewers again, with no cap that counted it.
#[test]
#[cfg(unix)]
fn a_second_look_that_could_not_post_waits_to_try_again_rather_than_every_sweep() {
let (l, after) = moved_since_its_review("listenrecheckfail", RECHECK_FIXED, RECHECK_FIXED);
let (ok, out, _) = l.run_with(&["--once"], &[("SPAR_FAKE_REVIEW_FAILS", "1")]);
assert!(!ok, "{out}");
assert!(
out.contains("#7: about to take a second look after a push"),
"{out}"
);
assert_eq!(2, l.agent_calls(), "{out}");
for _ in 0..2 {
let (_, out, _) = l.run_with(&["--once"], &[("SPAR_FAKE_REVIEW_FAILS", "1")]);
assert!(!out.contains("head moved"), "{out}");
assert!(!out.contains("about to take a second look"), "{out}");
}
assert_eq!(2, l.agent_calls());
let state = l.state();
assert_eq!(after, state["items"]["7"]["head"], "{state}");
assert!(state["pending"]["7"].is_object(), "{state}");
assert_eq!(0, read_record(&l.fx, 7).rechecks);
}
/// With `pr_comments = "none"` a second look posts nothing by design and
/// leaves the record alone, which made it the same loop as a failed post.
#[test]
#[cfg(unix)]
fn a_second_look_that_posts_nothing_by_design_is_not_raised_again() {
let (l, _) = moved_since_its_review("listenrechecksilent", RECHECK_FIXED, RECHECK_FIXED);
l.configure("[style]\npr_comments = \"none\"\n");
let (ok, out, log) = l.run(&["--once"]);
assert!(ok, "{out}");
assert!(out.contains("pr_comments is none, nothing posted"), "{out}");
assert!(!submitted_a_review(&log), "{log}");
assert_eq!(2, l.agent_calls(), "{out}");
let (ok, out, _) = l.run(&["--once"]);
assert!(ok, "{out}");
assert!(!out.contains("head moved"), "{out}");
assert_eq!(2, l.agent_calls(), "{out}");
}
/// `spar listen 42` acts on #42 and nothing else. Work a run over the whole
/// repository left pending in listen.json, and the items it was watching, used
/// to be dispatched and admitted by a run that named something else.
#[test]
#[cfg(unix)]
fn a_named_watch_leaves_unrelated_saved_work_alone() {
let l = Listening::refusing("listennamedonly", &listen_gh(7, "aaa", "alice", "OPEN"));
let dir = l.fx.work.join(".spar/state");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("listen.json"),
serde_json::to_string_pretty(&serde_json::json!({
"version": 1,
"watermark": "2026-10-20T00:00:00Z",
"items": {"7": {"kind": "pr", "head": "aaa"}},
"pending": {"7": {
"kind": "pr",
"first_seen": 0,
"last_seen": 0,
"triggers": ["review_requested"]
}},
"swept_once": true
}))
.unwrap(),
)
.unwrap();
l.with_notifications(&[notification("1", "comment", "PullRequest", 7)]);
let (ok, out, _) = l.run(&["42", "--once", "--dry-run"]);
assert!(ok, "{out}");
assert!(
out.contains("listening to example/project for #42"),
"{out}"
);
assert!(!out.contains("#7"), "{out}");
}
/// A named issue left the watch only when a notification about it arrived,
/// and an issue the viewer never took part in can close without one, so the
/// listener waited for it forever.
#[test]
#[cfg(unix)]
fn listen_exits_when_a_named_issue_closes_without_a_notification() {
let l = Listening::refusing("listenissueclosed", &listen_gh(7, "aaa", "alice", "OPEN"));
let (ok, out, _) = l.run_with(&["42"], &[("SPAR_FAKE_ISSUE_STATE", "CLOSED")]);
assert!(ok, "{out}");
assert!(out.contains("#42: closed, no longer watched"), "{out}");
assert!(
out.contains("every named item is closed, nothing left to watch"),
"{out}"
);
}
/// A mention on an issue rechecked its pull request with none of that pull
/// request's guards, so the cap that stopped the pull request's own second
/// look did not stop this one.
#[test]
#[cfg(unix)]
fn an_issue_mention_does_not_recheck_a_pull_request_past_its_cap() {
let l = Listening::refusing("listenissuecap", &listen_gh(7, "aaa", "alice", "OPEN"));
write_record(&l.fx, 7, &blocking_record("0000000"));
l.configure("[listen]\nmax_rechecks = 0\n");
l.with_prs(&[sweep_row(7, "aaa", "alice")]);
let linked = l.fx.dir.join("linked.json");
std::fs::write(
&linked,
r#"[{"number":7,"url":"https://github.com/example/project/pull/7","title":"PR 7","baseRefName":"main","closingIssuesReferences":[{"number":42}]}]"#,
)
.unwrap();
l.with_notifications(&[notification("1", "mention", "Issue", 42)]);
let (ok, out, _) = l.run_with(
&["--once"],
&[("SPAR_FAKE_LINKED", linked.to_str().unwrap())],
);
assert!(ok, "{out}");
assert!(
out.contains(
"#42: its pull request #7 was rechecked 0 times already; run `spar review 7` to look \
again"
),
"{out}"
);
assert!(!out.contains("about to take a second look"), "{out}");
assert_eq!(0, l.agent_calls(), "{out}");
}
/// A poll is all or nothing. A second page that failed used to be skipped
/// while the first page's threads were read and the watermark moved past
/// them, and GitHub, asked for what changed since, never returned the older
/// threads on the page that failed.
#[test]
#[cfg(unix)]
fn a_poll_whose_second_page_fails_reads_nothing_and_the_next_poll_reads_both() {
let l = Listening::refusing("listenpages", &listen_gh(7, "aaa", "alice", "OPEN"));
l.with_notifications(&[notification("2", "mention", "Issue", 42)]);
let page2 = l.fx.dir.join("notify-2.json");
std::fs::write(
&page2,
format!("[{}]\n", notification("1", "comment", "PullRequest", 7)),
)
.unwrap();
let page2 = page2.to_str().unwrap();
let (ok, out, _) = l.run_with(
&["--once"],
&[
("SPAR_FAKE_NOTIFY_PAGE2", page2),
("SPAR_FAKE_PAGE2_FAILS", "1"),
],
);
assert!(!ok, "{out}");
assert!(
out.contains("could not read notifications: HTTP 500: Server Error"),
"{out}"
);
assert!(!out.contains("#42: mentioned"), "{out}");
let state = l.state();
assert!(
state["seen"].as_object().is_some_and(|s| s.is_empty()),
"{state}"
);
assert!(state["last_modified"].is_null(), "{state}");
let (ok, out, _) = l.run_with(&["--once"], &[("SPAR_FAKE_NOTIFY_PAGE2", page2)]);
assert!(ok, "{out}");
assert!(out.contains("#42: mentioned"), "{out}");
assert!(out.contains("#7: somebody commented"), "{out}");
assert_eq!(
2,
l.state()["seen"].as_object().map(|s| s.len()).unwrap_or(0)
);
}
/// After a reply, a comments read that failed was taken for nobody having
/// said anything, so the second look reported nothing changed, and the reply,
/// already read past, was never looked at again.
#[test]
#[cfg(unix)]
fn a_reply_whose_comments_cannot_be_read_is_kept_for_another_try() {
let l = Listening::refusing("listenreplyfail", &listen_gh(7, "aaa", "alice", "OPEN"));
write_record(&l.fx, 7, &blocking_record("aaa"));
l.with_prs(&[sweep_row(7, "aaa", "alice")]);
l.with_notifications(&[notification("1", "comment", "PullRequest", 7)]);
let (ok, out, _) = l.run_with(&["--once"], &[("SPAR_FAKE_COMMENTS_FAIL", "1")]);
assert!(!ok, "{out}");
assert!(
out.contains("#7: about to take a second look after a reply"),
"{out}"
);
assert!(!out.contains("nothing changed since the review"), "{out}");
let state = l.state();
assert!(state["pending"]["7"].is_object(), "{state}");
assert_eq!(1, state["items"]["7"]["failures"], "{state}");
assert_eq!(0, l.agent_calls(), "{out}");
}
/// Named items end the listener when they are done, so `spar listen 7` is a
/// command that finishes rather than a process to remember.
#[test]
#[cfg(unix)]
fn listen_exits_when_every_named_item_is_closed() {
let l = Listening::refusing("listennamed", &listen_gh(7, "aaa", "alice", "MERGED"));
let (ok, out, _) = l.run(&["7"]);
assert!(ok, "{out}");
assert!(out.contains("listening to example/project for #7"), "{out}");
assert!(out.contains("#7: closed, no longer watched"), "{out}");
assert!(
out.contains("every named item is closed, nothing left to watch"),
"{out}"
);
assert_eq!(0, l.agent_calls());
assert!(!l.lock_exists());
}
#[test]
#[cfg(unix)]
fn listen_once_fails_when_the_poll_fails() {
let l = Listening::refusing("listenpollfail", &listen_gh(7, "aaa", "alice", "OPEN"));
let mut env = l.env();
env.push(("SPAR_FAKE_POLL_FAILS".into(), "1".into()));
let env: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let (ok, _, err) = spar_with_env(
&[
"listen",
"--once",
"--config",
l.config.to_str().unwrap(),
"--repo",
l.fx.work.to_str().unwrap(),
],
&l.fx.dir,
&env,
);
assert!(!ok, "{err}");
assert!(
err.contains("WARNING: could not read notifications"),
"{err}"
);
assert!(err.contains("rate limit exceeded"), "{err}");
assert_eq!(0, l.agent_calls());
assert!(!l.lock_exists());
}
/// Ctrl-C has to stop the agent call in flight, not leave it running in its
/// own process group with nobody to read its answer, and the listener has to
/// say what it was doing and exit 130.
#[test]
#[cfg(unix)]
fn listen_stops_a_running_agent_on_ctrl_c_and_exits_130() {
use std::io::Read;
let l = Listening::refusing("listeninterrupt", &listen_gh(7, "aaa", "alice", "OPEN"));
l.publish_head(7, "alice");
let pid_file = l.fx.dir.join("agent.pid");
let sleeper = format!(
"#!/bin/sh\necho $$ > \"{}\"\nsleep 300\n",
pid_file.display()
);
let bin = l.fx.dir.join("fake-bin");
executable(&bin.join("agent-a"), &sleeper);
executable(&bin.join("agent-b"), &sleeper);
// Act at once rather than waiting for the item to settle.
let mut config = std::fs::read_to_string(&l.config).unwrap();
config.push_str("\n[listen]\nsettle = 0\n");
std::fs::write(&l.config, config).unwrap();
l.with_notifications(&[notification("1", "review_requested", "PullRequest", 7)]);
l.asking_the_viewer();
let env = l.env();
let mut command = Command::new(SPAR_BIN);
command
.args([
"listen",
"--config",
l.config.to_str().unwrap(),
"--repo",
l.fx.work.to_str().unwrap(),
])
.current_dir(&l.fx.dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
for (k, v) in &env {
command.env(k, v);
}
let mut child = command.spawn().expect("spar listen starts");
let started = std::time::Instant::now();
while !pid_file.exists() {
assert!(
started.elapsed() < std::time::Duration::from_secs(20),
"the agent never started"
);
std::thread::sleep(std::time::Duration::from_millis(50));
}
std::thread::sleep(std::time::Duration::from_millis(200));
let agent_pid: u32 = std::fs::read_to_string(&pid_file)
.unwrap()
.trim()
.parse()
.unwrap();
let spar_pid = child.id();
assert!(Command::new("kill")
.args(["-INT", &spar_pid.to_string()])
.status()
.unwrap()
.success());
let mut stderr = child.stderr.take().unwrap();
let mut err = String::new();
let reader = std::thread::spawn(move || {
stderr.read_to_string(&mut err).unwrap();
err
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
panic!("spar listen did not stop within twenty seconds of Ctrl-C");
}
std::thread::sleep(std::time::Duration::from_millis(50));
};
let err = reader.join().unwrap();
assert_eq!(Some(130), status.code(), "{err}");
assert!(err.contains("interrupted while working on #7"), "{err}");
let gone = !Command::new("kill")
.args(["-0", &agent_pid.to_string()])
.status()
.unwrap()
.success();
assert!(gone, "the agent (pid {agent_pid}) kept running:\n{err}");
assert!(!l.lock_exists(), "the lock was left behind:\n{err}");
}