Skip to main content

spar/
repo.rs

1//! git and gh. Every outbound string passes through the style and concision
2//! gates before it reaches GitHub.
3
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7use serde::Deserialize;
8use serde_json::Value;
9
10use crate::config::{Config, Followups, StateStore};
11use crate::error::Result;
12use crate::model::{Issue, IssueRef, ItemKind, PersistedState, PrRef, PrView};
13use crate::proc::{self, ExecOpts};
14use crate::style::{self, Style};
15use crate::{bail, logdim, spar_err};
16
17/// gh returns newest first, so its `--limit` cannot be used to take the lowest
18/// numbered items: it would slice the newest N and then sorting that slice
19/// silently drops the older ones. Fetch a generous page, sort, then truncate.
20pub const FETCH_CEILING: usize = 500;
21
22/// An unclosed HTML comment on purpose. The payload is written after it and
23/// terminated with `-->`, so GitHub renders the whole block as nothing.
24pub const STATE_MARKER: &str = "<!-- spar:state";
25
26const WORKTREE_DIR: &str = ".spar-worktrees";
27const STATE_DIR: &str = ".spar";
28
29#[derive(Debug)]
30pub struct Repo {
31    root: PathBuf,
32    pub style: Style,
33    pub branch_prefix: String,
34    pub state_store: StateStore,
35    pub followups: Followups,
36}
37
38impl Repo {
39    pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
40        let root =
41            std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
42        // A linked worktree has a `.git` file rather than a directory, and a
43        // bare-ish layout can have neither, so ask git instead of guessing.
44        let inside = proc::run_str(
45            &["git", "rev-parse", "--is-inside-work-tree"],
46            &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
47        )
48        .unwrap_or_default();
49        if inside.trim() != "true" {
50            bail!("not a git repository: {}", root.display());
51        }
52        let repo = Self {
53            root,
54            style: cfg.style.clone(),
55            branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
56            state_store: cfg.loop_cfg.state_store,
57            followups: cfg.loop_cfg.followups,
58        };
59        repo.self_exclude();
60        Ok(repo)
61    }
62
63    /// Keep spar's own scratch directories out of the target repo's
64    /// `git status`.
65    ///
66    /// Written to `.git/info/exclude`, never to a tracked `.gitignore`: this is
67    /// somebody else's repository and spar has no business committing to it.
68    /// Best effort and silent on failure, because a read-only git directory is
69    /// not a reason to abandon a run.
70    fn self_exclude(&self) {
71        let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
72        let git_dir = git_dir.trim();
73        if git_dir.is_empty() {
74            return;
75        }
76        let path = Path::new(git_dir).join("info").join("exclude");
77        let existing = std::fs::read_to_string(&path).unwrap_or_default();
78
79        let wanted = [format!("/{WORKTREE_DIR}/"), format!("/{STATE_DIR}/")];
80        let missing: Vec<&String> = wanted
81            .iter()
82            .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
83            .collect();
84        if missing.is_empty() {
85            return;
86        }
87
88        use std::io::Write;
89        if let Some(parent) = path.parent() {
90            let _ = std::fs::create_dir_all(parent);
91        }
92        let mut block = String::new();
93        if !existing.is_empty() && !existing.ends_with('\n') {
94            block.push('\n');
95        }
96        block.push_str("\n# added by spar: its worktrees and run state\n");
97        for line in missing {
98            block.push_str(line);
99            block.push('\n');
100        }
101        if let Ok(mut file) = std::fs::OpenOptions::new()
102            .create(true)
103            .append(true)
104            .open(&path)
105        {
106            let _ = file.write_all(block.as_bytes());
107        }
108    }
109
110    pub fn root(&self) -> &Path {
111        &self.root
112    }
113
114    // -- gates ------------------------------------------------------------
115
116    /// Scrub, then verify. A leak here reaches GitHub, so it is a hard error
117    /// rather than a warning: silent partial compliance is how a style rule
118    /// erodes over a long run.
119    pub fn clean(&self, text: &str) -> Result<String> {
120        let out = style::scrub(text, &self.style);
121        let bad = style::violations(&out, &self.style);
122        if !bad.is_empty() {
123            bail!(
124                "style gate could not clean text ({}): {}",
125                bad.join(", "),
126                style::clip(&out, 300)
127            );
128        }
129        Ok(out)
130    }
131
132    /// Clean, and hold to a length budget. For anything a model wrote.
133    pub fn clean_body(&self, text: &str) -> Result<String> {
134        self.clean(&style::body(text, &self.style))
135    }
136
137    /// The single transform every outbound title goes through.
138    ///
139    /// Scrub first, clip second, and never the other way round. Clipping first
140    /// lets the scrub lengthen the result past the budget (an em dash becomes
141    /// two characters), so a second pass would clip again and produce a
142    /// different string. That broke follow-up deduplication silently: the
143    /// lookup searched for one title while GitHub had stored another, no match
144    /// was ever found, and a fresh duplicate issue was filed every review
145    /// round. Doing it in this order makes the transform idempotent, which the
146    /// tests assert.
147    pub fn clean_title(&self, text: &str) -> Result<String> {
148        Ok(style::title(&self.clean(text)?, &self.style))
149    }
150
151    // -- git --------------------------------------------------------------
152
153    fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
154        ExecOpts::new()
155            .cwd(cwd.unwrap_or(&self.root))
156            .check(check)
157            .timeout_secs(600)
158    }
159
160    pub fn git(&self, args: &[&str]) -> Result<String> {
161        self.git_at(None, args)
162    }
163
164    pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
165        let mut argv = vec!["git".to_string()];
166        argv.extend(args.iter().map(|s| s.to_string()));
167        proc::run(&argv, &self.git_opts(cwd, true))
168    }
169
170    /// Run git, tolerating failure. Returns whatever landed on stdout.
171    pub fn git_try(&self, args: &[&str]) -> String {
172        self.git_try_at(None, args)
173    }
174
175    pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
176        let mut argv = vec!["git".to_string()];
177        argv.extend(args.iter().map(|s| s.to_string()));
178        proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
179    }
180
181    /// The base branch the remote actually points at, rather than assuming
182    /// `main`. Falls back to the configured value when there is no origin.
183    pub fn default_branch(&self, configured: &str) -> String {
184        let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
185        match refname.trim().rsplit('/').next() {
186            Some(name) if !name.is_empty() => name.to_string(),
187            _ => configured.to_string(),
188        }
189    }
190
191    // -- branch naming and ownership --------------------------------------
192    //
193    // Branch names default to `issue-N`, which is exactly what a person would
194    // name a branch by hand. Ownership therefore cannot be inferred from the
195    // name, so every branch spar creates is recorded and cleanup only ever
196    // touches what is in that record.
197
198    pub fn branch_for_issue(&self, issue: i64) -> String {
199        format!("{}issue-{issue}", self.branch_prefix)
200    }
201
202    pub fn branch_for_pr(&self, number: i64) -> String {
203        format!("{}pr-{number}", self.branch_prefix)
204    }
205
206    fn ledger_path(&self) -> PathBuf {
207        self.root.join(STATE_DIR).join("branches.json")
208    }
209
210    pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
211        std::fs::read_to_string(self.ledger_path())
212            .ok()
213            .and_then(|text| serde_json::from_str(&text).ok())
214            .unwrap_or_default()
215    }
216
217    pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
218        let mut data = self.known_branches();
219        data.insert(
220            branch.to_string(),
221            BranchRecord {
222                kind: kind.to_string(),
223                number,
224            },
225        );
226        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
227            logdim!("could not record branch {branch}: {e}");
228        }
229    }
230
231    pub fn forget_branch(&self, branch: &str) {
232        let mut data = self.known_branches();
233        if data.remove(branch).is_none() {
234            return;
235        }
236        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
237            logdim!("could not update the branch record: {e}");
238        }
239    }
240
241    // -- worktrees --------------------------------------------------------
242
243    fn worktree_path(&self, name: &str) -> PathBuf {
244        self.root.join(WORKTREE_DIR).join(name)
245    }
246
247    /// Isolate an issue so a failed run cannot poison the next one's base.
248    pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
249        let branch = self.branch_for_issue(issue);
250        let path = self.worktree_path(&format!("issue-{issue}"));
251
252        self.git_try(&["fetch", "origin", base]);
253
254        // Never rebuild a branch that already carries work.
255        //
256        // `run_issue` sends an issue with an open pull request to the resume
257        // path, so reaching here with a remote branch ahead of the base means
258        // commits were pushed that no open PR accounts for. Rebuilding would
259        // force push over them, and the lease is no protection: the remote
260        // tracking ref survives the local branch being deleted, so it still
261        // matches and the push succeeds.
262        self.git_try(&["fetch", "origin", &branch]);
263        let remote_branch = format!("origin/{branch}");
264        if self.rev_exists(&self.root, &remote_branch) {
265            let range = format!("origin/{base}..{remote_branch}");
266            let ahead: u32 = self
267                .git_try(&["rev-list", "--count", &range])
268                .trim()
269                .parse()
270                .unwrap_or(0);
271            if ahead > 0 {
272                bail!(
273                    "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
274                     open pull request accounts for them. Rebuilding it would force push over \
275                     that work.\nOpen a pull request for the branch and run `spar resume <pr>` to \
276                     continue it, or delete it with `git push origin --delete {branch}` if it is \
277                     stale."
278                );
279            }
280        }
281
282        self.worktree_remove(issue);
283        self.git_try(&["branch", "-D", &branch]);
284
285        if let Some(parent) = path.parent() {
286            std::fs::create_dir_all(parent)
287                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
288        }
289
290        let path_str = path.display().to_string();
291        let remote_start = format!("origin/{base}");
292        let created = self
293            .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
294            .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
295
296        // Recorded on both paths: an unrecorded branch is one cleanup will
297        // never remove, and the fallback creates a branch just the same.
298        created.map_err(|e| {
299            spar_err!(
300                "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
301                 and does `origin` exist?",
302                e.last_line()
303            )
304        })?;
305        self.record_branch(&branch, "issue", issue);
306        Ok((path, branch))
307    }
308
309    pub fn worktree_remove(&self, issue: i64) {
310        self.remove_worktree_at(&self.worktree_path(&format!("issue-{issue}")));
311    }
312
313    fn remove_worktree_at(&self, path: &Path) {
314        let path_str = path.display().to_string();
315        self.git_try(&["worktree", "remove", "--force", &path_str]);
316        if path.is_dir() {
317            let _ = std::fs::remove_dir_all(path);
318        }
319        self.git_try(&["worktree", "prune"]);
320    }
321
322    /// Check an existing PR branch out into an isolated worktree.
323    pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
324        let head = pr.head_ref_name.clone();
325        if head.trim().is_empty() {
326            bail!("PR #{} has no head branch to check out", pr.number);
327        }
328        let path = self.worktree_path(&format!("pr-{}", pr.number));
329        let local = self.branch_for_pr(pr.number);
330
331        self.git(&["fetch", "origin", &head]).map_err(|e| {
332            spar_err!(
333                "could not fetch the branch behind PR #{}: {}",
334                pr.number,
335                e.last_line()
336            )
337        })?;
338        self.remove_worktree_at(&path);
339        self.git_try(&["branch", "-D", &local]);
340
341        let path_str = path.display().to_string();
342        let start = format!("origin/{head}");
343        self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
344        self.record_branch(&local, "pr", pr.number);
345        Ok((path, head))
346    }
347
348    /// Check a pull request's head out read only, detached, with no branch.
349    ///
350    /// Fetches `refs/pull/N/head`, which GitHub serves for every pull request
351    /// including one from a fork whose branch is not in this repository at all.
352    /// That is what makes reviewing an outside contribution possible when
353    /// pushing to it is not.
354    ///
355    /// Detached on purpose. Review only mode has nothing to push, and a branch
356    /// would only invite something to try.
357    pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
358        let path = self.worktree_path(&format!("review-{number}"));
359        let local_ref = review_ref(number);
360        let refspec = format!("+refs/pull/{number}/head:{local_ref}");
361
362        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
363            spar_err!(
364                "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
365                 every pull request, so this usually means the number is wrong or `origin` does \
366                 not point at the repository the PR is on.",
367                e.last_line()
368            )
369        })?;
370
371        if let Some(parent) = path.parent() {
372            std::fs::create_dir_all(parent)
373                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
374        }
375        self.remove_worktree_at(&path);
376        let path_str = path.display().to_string();
377        self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
378        Ok(path)
379    }
380
381    pub fn release_review_worktree(&self, number: i64) {
382        self.remove_worktree_at(&self.worktree_path(&format!("review-{number}")));
383        self.git_try(&["update-ref", "-d", &review_ref(number)]);
384    }
385
386    pub fn release_pr_worktree(&self, number: i64) {
387        let path = self.worktree_path(&format!("pr-{number}"));
388        self.remove_worktree_at(&path);
389        let local = self.branch_for_pr(number);
390        self.git_try(&["branch", "-D", &local]);
391        self.forget_branch(&local);
392    }
393
394    // -- branch state -----------------------------------------------------
395
396    /// What to diff against: the remote tracking branch when it resolves, the
397    /// local branch when it does not.
398    ///
399    /// This is not a nicety. Every "did the agent do anything" check hangs off
400    /// this ref, and `git log` against a ref that does not exist fails silently
401    /// and reads as "no commits". A checkout whose `origin/main` was never
402    /// fetched would report every implementation as abandoned and throw the
403    /// work away.
404    pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
405        let remote = format!("origin/{base}");
406        if self.rev_exists(cwd, &remote) {
407            return remote;
408        }
409        if self.rev_exists(cwd, base) {
410            logdim!("origin/{base} does not resolve, comparing against local {base}");
411            return base.to_string();
412        }
413        logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
414        remote
415    }
416
417    fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
418        let spec = format!("{refname}^{{commit}}");
419        !self
420            .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
421            .trim()
422            .is_empty()
423    }
424
425    pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
426        let range = format!("{}..HEAD", self.base_ref(cwd, base));
427        !self
428            .git_try_at(Some(cwd), &["log", &range, "--oneline"])
429            .trim()
430            .is_empty()
431    }
432
433    pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
434        let range = format!("{}...HEAD", self.base_ref(cwd, base));
435        let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
436        full.trim().to_string()
437    }
438
439    /// Scrub commit messages that slipped past the prompt.
440    ///
441    /// `git filter-branch` calls back into this same binary, so there is no
442    /// interpreter to find and no second copy of the rules to drift.
443    pub fn rewrite_commits_if_needed(&self, cwd: &Path, base: &str) -> Result<()> {
444        let range = format!("{}..HEAD", self.base_ref(cwd, base));
445        let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
446
447        let offenders = raw
448            .split('\x1e')
449            .filter_map(|entry| entry.split_once('\0'))
450            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
451            .count();
452        if offenders == 0 {
453            return Ok(());
454        }
455        logdim!("{offenders} commit message(s) violated style rules, rewriting");
456
457        let exe = self_binary()?;
458        let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
459
460        let argv: Vec<String> = [
461            "git",
462            "filter-branch",
463            "-f",
464            "--msg-filter",
465            &filter,
466            &range,
467        ]
468        .iter()
469        .map(|s| s.to_string())
470        .collect();
471        let opts = ExecOpts::new()
472            .cwd(cwd)
473            .check(false)
474            .timeout_secs(600)
475            .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
476            .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
477            .env(
478                "SPAR_BAN_AI_ATTRIBUTION",
479                bool_env(self.style.ban_ai_attribution),
480            );
481        let _ = proc::run(&argv, &opts);
482
483        let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
484        if !style::violations(&after, &self.style).is_empty() {
485            bail!(
486                "commit messages still violate style rules after a rewrite. Fix them by hand in \
487                 {} and rerun.",
488                cwd.display()
489            );
490        }
491        Ok(())
492    }
493
494    /// Push by explicit refspec from HEAD.
495    ///
496    /// A resumed PR is checked out under a local name (`pr-N`) that does not
497    /// match its remote branch, so pushing by branch name would resolve the
498    /// wrong local ref or fail outright.
499    pub fn push(&self, cwd: &Path, branch: &str) -> Result<()> {
500        let refspec = format!("HEAD:{branch}");
501        self.git_at(
502            Some(cwd),
503            &["push", "--force-with-lease", "origin", &refspec],
504        )
505        .map(|_| ())
506        .map_err(|e| {
507            spar_err!(
508                "could not push to origin/{branch}. {}\nCheck push access and whether the \
509                     branch moved under you.",
510                e.last_line()
511            )
512        })
513    }
514
515    // -- gh ---------------------------------------------------------------
516
517    pub fn gh(&self, args: &[&str]) -> Result<String> {
518        self.gh_at(None, args)
519    }
520
521    pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
522        let mut argv = vec!["gh".to_string()];
523        argv.extend(args.iter().map(|s| s.to_string()));
524        proc::run(
525            &argv,
526            &ExecOpts::new()
527                .cwd(cwd.unwrap_or(&self.root))
528                .timeout_secs(300),
529        )
530    }
531
532    pub fn gh_try(&self, args: &[&str]) -> String {
533        let mut argv = vec!["gh".to_string()];
534        argv.extend(args.iter().map(|s| s.to_string()));
535        proc::run(
536            &argv,
537            &ExecOpts::new()
538                .cwd(&self.root)
539                .check(false)
540                .timeout_secs(300),
541        )
542        .unwrap_or_default()
543    }
544
545    pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
546        let mut issues = Vec::new();
547        for number in numbers {
548            let text = self
549                .gh(&[
550                    "issue",
551                    "view",
552                    &number.to_string(),
553                    "--json",
554                    "number,title,body,labels,state,url",
555                ])
556                .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
557            let issue: Issue = serde_json::from_str(&text)
558                .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
559            if issue.is_closed() {
560                crate::log!("issue #{number} is closed, skipping");
561                continue;
562            }
563            issues.push(issue);
564        }
565        if issues.is_empty() {
566            bail!("no open issues to work on");
567        }
568        Ok(issues)
569    }
570
571    fn open_numbers(&self, kind: &str, limit: usize) -> Result<Vec<i64>> {
572        #[derive(Deserialize)]
573        struct Row {
574            number: i64,
575        }
576        let text = self.gh(&[
577            kind,
578            "list",
579            "--state",
580            "open",
581            "--limit",
582            &FETCH_CEILING.to_string(),
583            "--json",
584            "number",
585        ])?;
586        let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
587        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
588        numbers.sort_unstable();
589
590        let noun = if kind == "issue" { "issues" } else { "PRs" };
591        if numbers.len() >= FETCH_CEILING {
592            crate::log!(
593                "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
594                 considered."
595            );
596        }
597        if numbers.len() > limit {
598            crate::log!(
599                "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
600                 explicitly.",
601                numbers.len()
602            );
603            numbers.truncate(limit);
604        }
605        Ok(numbers)
606    }
607
608    /// Open issues, lowest numbered first. `gh issue list` excludes PRs.
609    pub fn list_open_issues(&self, limit: usize) -> Result<Vec<i64>> {
610        self.open_numbers("issue", limit)
611    }
612
613    pub fn list_open_prs(&self, limit: usize) -> Result<Vec<i64>> {
614        self.open_numbers("pr", limit)
615    }
616
617    pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
618        let text = self.gh_try(&[
619            "pr",
620            "list",
621            "--head",
622            branch,
623            "--state",
624            "open",
625            "--json",
626            "number,url,title",
627        ]);
628        serde_json::from_str::<Vec<PrRef>>(text.trim())
629            .ok()
630            .and_then(|mut v| {
631                if v.is_empty() {
632                    None
633                } else {
634                    Some(v.remove(0))
635                }
636            })
637    }
638
639    /// Whether a number names an issue or a pull request.
640    ///
641    /// `gh issue view` happily returns a pull request when handed its number,
642    /// so it cannot be used to tell them apart. The issues API carries both and
643    /// marks a pull request with a `pull_request` key, which is definitive.
644    pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
645        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
646        let text = self
647            .gh(&[
648                "api",
649                &path,
650                "--jq",
651                "if .pull_request then \"pr\" else \"issue\" end",
652            ])
653            .map_err(|e| {
654                spar_err!(
655                    "no issue or pull request #{number} in this repository. {}",
656                    e.last_line()
657                )
658            })?;
659        match text.trim() {
660            "pr" => Ok(ItemKind::Pr),
661            "issue" => Ok(ItemKind::Issue),
662            other => Err(spar_err!(
663                "could not tell whether #{number} is an issue or a pull request (got {other:?})"
664            )),
665        }
666    }
667
668    /// An open pull request that would close this issue, whoever opened it.
669    ///
670    /// spar's own branch naming is checked first because it is exact and cheap.
671    /// Falling back to GitHub's own issue linkage is what lets spar pick up a
672    /// pull request a person started on a branch named anything at all.
673    pub fn open_pr_for_issue(&self, issue: i64) -> Option<PrRef> {
674        if let Some(pr) = self.pr_for_branch(&self.branch_for_issue(issue)) {
675            return Some(pr);
676        }
677        let text = self.gh_try(&[
678            "pr",
679            "list",
680            "--state",
681            "open",
682            "--limit",
683            &FETCH_CEILING.to_string(),
684            "--json",
685            "number,url,title,closingIssuesReferences",
686        ]);
687        find_linked_pr(&text, issue)
688    }
689
690    pub fn pr_view(&self, number: i64) -> Result<PrView> {
691        let text = self.gh(&[
692            "pr",
693            "view",
694            &number.to_string(),
695            "--json",
696            "number,url,title,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
697        ])?;
698        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
699    }
700
701    pub fn pr_state(&self, number: i64) -> String {
702        let text = self.gh_try(&["pr", "view", &number.to_string(), "--json", "state"]);
703        serde_json::from_str::<Value>(text.trim())
704            .ok()
705            .and_then(|v| v.get("state").and_then(Value::as_str).map(str::to_string))
706            .unwrap_or_default()
707    }
708
709    pub fn create_pr(
710        &self,
711        cwd: &Path,
712        branch: &str,
713        base: &str,
714        title: &str,
715        body: &str,
716    ) -> Result<PrRef> {
717        let title = self.clean_title(title)?;
718        let body = self.clean(body)?;
719        self.gh_at(
720            Some(cwd),
721            &[
722                "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body",
723                &body,
724            ],
725        )
726        .map_err(|e| spar_err!("could not open a PR for {branch}. {}", e.last_line()))?;
727        self.pr_for_branch(branch).ok_or_else(|| {
728            spar_err!("PR creation reported success but none was found for {branch}")
729        })
730    }
731
732    pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
733        let body = self.clean(body)?;
734        self.gh(&["pr", "comment", &number.to_string(), "--body", &body])
735            .map(|_| ())
736    }
737
738    pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
739        let body = self.clean(body)?;
740        self.gh(&["issue", "comment", &number.to_string(), "--body", &body])
741            .map(|_| ())
742    }
743
744    /// Comment, then close as not planned.
745    ///
746    /// Only ever called when both agents independently declined the issue: one
747    /// agent's opinion is not enough to close somebody's report.
748    pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
749        self.comment_issue(number, body)?;
750        let n = number.to_string();
751        if self
752            .gh(&["issue", "close", &n, "--reason", "not planned"])
753            .is_ok()
754        {
755            return Ok(());
756        }
757        // Older gh builds do not take --reason.
758        self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
759            spar_err!(
760                "commented on #{number} but could not close it: {}",
761                e.last_line()
762            )
763        })
764    }
765
766    pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
767        let title = self.clean_title(title)?;
768        let body = self.clean_body(body)?;
769        Ok(self
770            .gh(&["issue", "create", "--title", &title, "--body", &body])?
771            .trim()
772            .to_string())
773    }
774
775    /// Avoid filing a duplicate when a follow-up already exists.
776    pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
777        #[derive(Deserialize)]
778        struct Row {
779            title: String,
780            url: String,
781        }
782        let needle = title.trim().to_lowercase();
783        if needle.is_empty() {
784            return None;
785        }
786        // Quotes and newlines would be read as search syntax rather than text.
787        let query: String = title
788            .chars()
789            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
790            .take(120)
791            .collect();
792        let text = self.gh_try(&[
793            "issue",
794            "list",
795            "--state",
796            "all",
797            "--limit",
798            "100",
799            "--search",
800            query.trim(),
801            "--json",
802            "number,title,url",
803        ]);
804        serde_json::from_str::<Vec<Row>>(text.trim())
805            .ok()?
806            .into_iter()
807            .find(|row| row.title.trim().to_lowercase() == needle)
808            .map(|row| row.url)
809    }
810
811    /// Squash merge, tolerating cleanup failures after a successful merge.
812    ///
813    /// `gh pr merge --delete-branch` exits non-zero when it cannot delete the
814    /// local branch, which happens *after* the merge has already landed.
815    /// Treating that as a failure reports work as lost when it is not.
816    pub fn merge_pr(&self, number: i64) -> Result<()> {
817        let n = number.to_string();
818        match self.gh(&["pr", "merge", &n, "--squash", "--delete-branch"]) {
819            Ok(_) => Ok(()),
820            Err(e) => {
821                if self.pr_state(number) == "MERGED" {
822                    logdim!(
823                        "PR #{number} merged; branch cleanup did not finish: {}",
824                        e.last_line()
825                    );
826                    Ok(())
827                } else {
828                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
829                }
830            }
831        }
832    }
833
834    // -- follow-ups -------------------------------------------------------
835
836    /// Append a follow-up to a local note instead of the tracker.
837    ///
838    /// Deduplicated on the title, matching the issue path. Returns a display
839    /// string, or None when it was already recorded.
840    pub fn append_local_followup(&self, title: &str, body: &str, source: i64) -> Option<String> {
841        let path = self.root.join(STATE_DIR).join("followups.md");
842        let heading = format!("## {}", title.trim());
843        if let Ok(existing) = std::fs::read_to_string(&path) {
844            if existing.contains(&heading) {
845                logdim!("follow-up already noted: {title}");
846                return None;
847            }
848        }
849        if let Some(parent) = path.parent() {
850            let _ = std::fs::create_dir_all(parent);
851        }
852        use std::io::Write;
853        let entry = format!("{heading}\n\nFrom #{source}.\n\n{}\n\n", body.trim());
854        match std::fs::OpenOptions::new()
855            .create(true)
856            .append(true)
857            .open(&path)
858        {
859            Ok(mut file) => {
860                let _ = file.write_all(entry.as_bytes());
861                Some(format!("note: {}", title.trim()))
862            }
863            Err(e) => {
864                logdim!("could not write {}: {e}", path.display());
865                None
866            }
867        }
868    }
869
870    // -- resumable state --------------------------------------------------
871    //
872    // Custody cannot be read from GitHub authorship: every agent commits and
873    // comments as the same git identity, so `author` is always the human who
874    // ran spar. State is kept on disk by default and can additionally travel in
875    // a PR comment, which is what lets a run be resumed from another machine.
876
877    pub fn state_path(&self, number: i64) -> PathBuf {
878        self.root
879            .join(STATE_DIR)
880            .join("state")
881            .join(format!("pr-{number}.json"))
882    }
883
884    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
885        let path = self.state_path(number);
886        let text = std::fs::read_to_string(&path).ok()?;
887        match serde_json::from_str(&text) {
888            Ok(state) => Some(state),
889            Err(_) => {
890                logdim!("could not read {}, starting fresh", path.display());
891                None
892            }
893        }
894    }
895
896    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
897        if let Some(local) = self.read_local_state(pr.number) {
898            return Some(local);
899        }
900        if self.state_store.writes_pr() {
901            return self.read_pr_state(pr.number);
902        }
903        None
904    }
905
906    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
907        for body in self.state_comment_bodies(number).into_iter().rev() {
908            if let Some(state) = parse_state_comment(&body) {
909                return Some(state);
910            }
911        }
912        None
913    }
914
915    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
916        if self.state_store.writes_local() {
917            write_json_atomic(&self.state_path(number), state)?;
918        }
919        if self.state_store.writes_pr() {
920            self.write_pr_state(number, state)?;
921        }
922        Ok(())
923    }
924
925    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
926        // Not run through clean(): this is structured data, and scrubbing would
927        // corrupt refutation text stored in the ledger. It sits inside an
928        // unclosed HTML comment so GitHub renders it as nothing.
929        let body = format!(
930            "{STATE_MARKER}\n{}\n-->",
931            serde_json::to_string_pretty(state)?
932        );
933        if let Some(id) = self.state_comment_id(number) {
934            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
935            let field = format!("body={body}");
936            self.gh_try(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"]);
937            return Ok(());
938        }
939        self.gh(&["pr", "comment", &number.to_string(), "--body", &body])
940            .map(|_| ())
941    }
942
943    fn comments_json(&self, number: i64) -> Vec<Value> {
944        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
945        parse_comment_pages(&self.gh_try(&["api", "--paginate", &path]))
946    }
947
948    fn state_comments(&self, number: i64) -> Vec<(i64, String)> {
949        self.comments_json(number)
950            .into_iter()
951            .filter_map(|c| {
952                let body = c.get("body").and_then(Value::as_str)?.to_string();
953                if !body.contains("spar:state") {
954                    return None;
955                }
956                let id = c.get("id").and_then(Value::as_i64)?;
957                Some((id, body))
958            })
959            .collect()
960    }
961
962    fn state_comment_bodies(&self, number: i64) -> Vec<String> {
963        self.state_comments(number)
964            .into_iter()
965            .map(|(_, b)| b)
966            .collect()
967    }
968
969    fn state_comment_id(&self, number: i64) -> Option<i64> {
970        self.state_comments(number).last().map(|(id, _)| *id)
971    }
972
973    /// Drop state once the PR is finished and there is nothing to resume.
974    pub fn clear_state(&self, number: i64) {
975        let path = self.state_path(number);
976        let _ = std::fs::remove_file(&path);
977        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
978    }
979
980    // -- housekeeping -----------------------------------------------------
981
982    /// Remove state files whose PR is merged or closed.
983    pub fn prune_state(&self) -> Vec<String> {
984        let base = self.root.join(STATE_DIR).join("state");
985        let Ok(entries) = std::fs::read_dir(&base) else {
986            return Vec::new();
987        };
988        let mut names: Vec<String> = entries
989            .flatten()
990            .filter_map(|e| e.file_name().to_str().map(str::to_string))
991            .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
992            .collect();
993        names.sort();
994
995        let mut removed = Vec::new();
996        for name in names {
997            let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
998                continue;
999            };
1000            if is_finished(&self.pr_state(number)) {
1001                let _ = std::fs::remove_file(base.join(&name));
1002                removed.push(format!("state {name}"));
1003            }
1004        }
1005        removed
1006    }
1007
1008    /// Delete state comments from PRs that are finished.
1009    ///
1010    /// Open PRs are left alone: their state may still be live.
1011    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
1012        #[derive(Deserialize)]
1013        struct Row {
1014            number: i64,
1015        }
1016        let numbers = numbers.unwrap_or_else(|| {
1017            let text = self.gh_try(&[
1018                "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
1019            ]);
1020            serde_json::from_str::<Vec<Row>>(text.trim())
1021                .unwrap_or_default()
1022                .into_iter()
1023                .map(|r| r.number)
1024                .collect()
1025        });
1026
1027        let mut removed = Vec::new();
1028        for number in numbers {
1029            if !is_finished(&self.pr_state(number)) {
1030                continue;
1031            }
1032            for (id, _) in self.state_comments(number) {
1033                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
1034                self.gh_try(&["api", "-X", "DELETE", &path, "--silent"]);
1035                removed.push(format!("state comment on PR #{number}"));
1036            }
1037        }
1038        removed
1039    }
1040
1041    /// Drop worktrees whose PR is finished, then the branches they left behind.
1042    ///
1043    /// With auto_merge off, which is the default, a run ends at "approved", so
1044    /// nothing would ever clean these up on its own and they accumulate one per
1045    /// run. A stranded worktree also holds its branch checked out, which makes
1046    /// a later `gh pr merge --delete-branch` fail to clean up.
1047    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
1048        let base = self.root.join(WORKTREE_DIR);
1049        let mut removed = Vec::new();
1050
1051        if let Ok(entries) = std::fs::read_dir(&base) {
1052            let mut names: Vec<String> = entries
1053                .flatten()
1054                .filter(|e| e.path().is_dir())
1055                .filter_map(|e| e.file_name().to_str().map(str::to_string))
1056                .collect();
1057            names.sort();
1058
1059            for name in names {
1060                // A review worktree is detached and owns no branch, so it is
1061                // tied to the pull request only by its directory name.
1062                if let Some(rest) = name.strip_prefix("review-") {
1063                    let number: i64 = rest.parse().unwrap_or(-1);
1064                    if !(force_all || is_finished(&self.pr_state(number))) {
1065                        continue;
1066                    }
1067                    self.release_review_worktree(number);
1068                    removed.push(name);
1069                    continue;
1070                }
1071                let branch = format!("{}{name}", self.branch_prefix);
1072                if !(force_all || self.worktree_is_done(&branch)) {
1073                    continue;
1074                }
1075                self.remove_worktree_at(&base.join(&name));
1076                self.git_try(&["branch", "-D", &branch]);
1077                self.forget_branch(&branch);
1078                removed.push(name);
1079            }
1080        }
1081        if !removed.is_empty() {
1082            self.git_try(&["worktree", "prune"]);
1083        }
1084        removed.extend(self.prune_branches(force_all));
1085        removed
1086    }
1087
1088    /// Delete leftover branches spar created whose worktree is already gone.
1089    ///
1090    /// Deletion is driven by the ledger of branches spar actually created, not
1091    /// by a name pattern. Names default to `issue-N`, which is exactly what a
1092    /// person would call a branch themselves, so a name alone can never
1093    /// establish ownership. This is the data loss guard.
1094    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
1095        let branches: Vec<String> = self.known_branches().keys().cloned().collect();
1096        if branches.is_empty() {
1097            return Vec::new();
1098        }
1099
1100        let checked_out: Vec<String> = self
1101            .git_try(&["worktree", "list", "--porcelain"])
1102            .lines()
1103            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
1104            .collect();
1105
1106        // %(refname:short) is ambiguous when a tag shares the branch name (it
1107        // yields "heads/..."), so take the full ref and strip it here.
1108        let existing: Vec<String> = self
1109            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
1110            .lines()
1111            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
1112            .collect();
1113
1114        let mut removed = Vec::new();
1115        for branch in branches {
1116            if !existing.contains(&branch) {
1117                self.forget_branch(&branch); // already gone, drop the record
1118                continue;
1119            }
1120            if checked_out.contains(&branch) {
1121                continue;
1122            }
1123            if !(force_all || self.worktree_is_done(&branch)) {
1124                continue;
1125            }
1126            match self.git(&["branch", "-D", &branch]) {
1127                Ok(_) => {
1128                    self.forget_branch(&branch);
1129                    removed.push(format!("branch {branch}"));
1130                }
1131                Err(e) => {
1132                    // A branch that silently survives pruning looks like a spar
1133                    // bug, so the name and git's own reason have to be said.
1134                    logdim!("could not delete {branch}: {}", e.last_line());
1135                }
1136            }
1137        }
1138        removed
1139    }
1140
1141    /// True when the PR behind this branch is merged or closed.
1142    fn worktree_is_done(&self, branch: &str) -> bool {
1143        #[derive(Deserialize)]
1144        struct Row {
1145            state: String,
1146        }
1147        let entry = branch
1148            .strip_prefix(self.branch_prefix.as_str())
1149            .unwrap_or(branch);
1150        if let Some(rest) = entry.strip_prefix("pr-") {
1151            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
1152        }
1153        if entry.starts_with("issue-") {
1154            let text = self.gh_try(&[
1155                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
1156            ]);
1157            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
1158            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
1159        }
1160        false
1161    }
1162}
1163
1164// ---------------------------------------------------------------------------
1165// Free helpers
1166// ---------------------------------------------------------------------------
1167
1168#[derive(Debug, Clone, serde::Serialize, Deserialize)]
1169pub struct BranchRecord {
1170    pub kind: String,
1171    pub number: i64,
1172}
1173
1174/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
1175/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
1176pub fn review_ref(number: i64) -> String {
1177    format!("refs/spar/pr-{number}")
1178}
1179
1180pub fn is_finished(state: &str) -> bool {
1181    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
1182}
1183
1184/// Write JSON through a temporary file and rename, so a kill cannot leave a
1185/// truncated state file behind.
1186pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
1187    if let Some(parent) = path.parent() {
1188        std::fs::create_dir_all(parent)
1189            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1190    }
1191    let tmp = path.with_extension(format!(
1192        "{}.tmp",
1193        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
1194    ));
1195    std::fs::write(&tmp, serde_json::to_vec_pretty(value)?)
1196        .map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
1197    std::fs::rename(&tmp, path)
1198        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
1199    Ok(())
1200}
1201
1202/// Among the open pull requests gh listed, the first that would close `issue`.
1203///
1204/// Separated from the gh call so the real payload shape can be tested. GitHub
1205/// returns far more per linked issue than the number, and silently failing to
1206/// parse it would look exactly like "no pull request exists", which is the
1207/// answer that makes spar implement over the top of somebody's work.
1208pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
1209    #[derive(Deserialize)]
1210    #[serde(rename_all = "camelCase")]
1211    struct Row {
1212        number: i64,
1213        #[serde(default)]
1214        url: String,
1215        #[serde(default)]
1216        title: String,
1217        #[serde(default)]
1218        closing_issues_references: Vec<IssueRef>,
1219    }
1220
1221    serde_json::from_str::<Vec<Row>>(json.trim())
1222        .ok()?
1223        .into_iter()
1224        .find(|row| {
1225            row.closing_issues_references
1226                .iter()
1227                .any(|linked| linked.number == issue)
1228        })
1229        .map(|row| PrRef {
1230            number: row.number,
1231            url: row.url,
1232            title: row.title,
1233        })
1234}
1235
1236/// Flatten whatever `gh api --paginate` printed into a list of comments.
1237///
1238/// Current gh merges array pages into one array. Older builds concatenated one
1239/// document per page. A streaming parser reads either, and unlike splitting the
1240/// text on a bracket pair it cannot be fooled by a comment body that happens to
1241/// contain one, which would otherwise make a resume silently start over.
1242pub fn parse_comment_pages(text: &str) -> Vec<Value> {
1243    let mut out = Vec::new();
1244    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
1245        match value {
1246            Ok(Value::Array(items)) => out.extend(items),
1247            Ok(other) => out.push(other),
1248            Err(_) => break,
1249        }
1250    }
1251    out
1252}
1253
1254/// Extract the payload from a state comment. The marker is followed by JSON and
1255/// terminated with `-->`.
1256pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
1257    let marker = body.find(STATE_MARKER)?;
1258    let start = body[marker..].find('{')? + marker;
1259    let end = body.rfind('}')?;
1260    if end <= start {
1261        return None;
1262    }
1263    match serde_json::from_str(&body[start..=end]) {
1264        Ok(state) => Some(state),
1265        Err(_) => {
1266            logdim!("found a spar state comment but could not parse it");
1267            None
1268        }
1269    }
1270}
1271
1272/// Where this binary lives, so `git filter-branch` can call back into it.
1273///
1274/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
1275/// tests, whose `current_exe` is the test harness rather than spar, and for
1276/// anyone who ships spar behind a wrapper script.
1277pub fn self_binary() -> Result<PathBuf> {
1278    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
1279        let path = PathBuf::from(path);
1280        if proc::is_executable(&path) {
1281            return Ok(path);
1282        }
1283        bail!(
1284            "SPAR_SELF_BIN is set to {}, which is not executable",
1285            path.display()
1286        );
1287    }
1288    std::env::current_exe()
1289        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
1290}
1291
1292fn bool_env(value: bool) -> &'static str {
1293    if value {
1294        "1"
1295    } else {
1296        "0"
1297    }
1298}
1299
1300/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
1301/// shell command, and an install path with a space in it is not exotic.
1302pub fn sh_quote(text: &str) -> String {
1303    format!("'{}'", text.replace('\'', r"'\''"))
1304}
1305
1306/// Style rules for the `scrub-filter` subcommand, which runs in a child process
1307/// spawned by git and so cannot see the parent's config.
1308pub fn style_from_env() -> Style {
1309    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
1310    Style {
1311        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
1312        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
1313        ..Style::permissive()
1314    }
1315}
1316
1317#[cfg(test)]
1318mod tests {
1319    use super::*;
1320    use crate::config::StateStore;
1321    use crate::model::{Ledger, Status};
1322
1323    fn repo_for_titles() -> Repo {
1324        Repo {
1325            root: PathBuf::from("/nonexistent"),
1326            style: Style::default(),
1327            branch_prefix: String::new(),
1328            state_store: StateStore::Local,
1329            followups: crate::config::Followups::Issues,
1330        }
1331    }
1332
1333    /// Follow-up deduplication compares a title it computed against the title
1334    /// GitHub stored. If those two transforms can disagree, the check never
1335    /// matches and every review round files another copy of the same issue.
1336    #[test]
1337    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
1338        let repo = repo_for_titles();
1339        for raw in [
1340            "Retry loop spins \u{2014} Retry-After parses to zero",
1341            "plain title",
1342            "  spread   over\nlines  ",
1343            "\u{1F916} Generated with something",
1344            &format!("a \u{2014} {}", "very long title ".repeat(20)),
1345            &"x".repeat(300),
1346            &format!("{} \u{2014} end", "y".repeat(88)),
1347            // Exactly the budget, with two spaceless dashes. The scrub turns
1348            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
1349            // character over budget per dash and a second pass clips again,
1350            // producing a different string. Scrub-then-clip cannot.
1351            &{
1352                let tail = "a\u{2014}b c\u{2014}d";
1353                let pad = Style::default().max_title_chars - tail.chars().count();
1354                format!("{}{tail}", "w".repeat(pad))
1355            },
1356        ] {
1357            let once = repo.clean_title(raw).unwrap();
1358            let twice = repo.clean_title(&once).unwrap();
1359            assert_eq!(once, twice, "not idempotent for {raw:?}");
1360            assert!(
1361                once.chars().count() <= repo.style.max_title_chars,
1362                "over budget: {once:?}"
1363            );
1364            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
1365        }
1366    }
1367
1368    #[test]
1369    fn a_title_with_an_em_dash_survives_as_readable_text() {
1370        let repo = repo_for_titles();
1371        assert_eq!(
1372            "Retry loop spins, Retry-After parses to zero",
1373            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
1374                .unwrap()
1375        );
1376    }
1377
1378    #[test]
1379    fn sh_quote_survives_a_quote() {
1380        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
1381    }
1382
1383    #[test]
1384    fn sh_quote_wraps_a_space() {
1385        assert_eq!(
1386            "'/Applications/My App/spar'",
1387            sh_quote("/Applications/My App/spar")
1388        );
1389    }
1390
1391    #[test]
1392    fn finished_states_are_recognised_case_insensitively() {
1393        assert!(is_finished("MERGED"));
1394        assert!(is_finished("closed"));
1395        assert!(!is_finished("OPEN"));
1396        assert!(!is_finished(""));
1397    }
1398
1399    fn state() -> PersistedState {
1400        PersistedState {
1401            version: 1,
1402            round: 4,
1403            next_actor: "codex".into(),
1404            status: Status::Pending,
1405            ledger: Ledger::new(),
1406            filed: vec![],
1407        }
1408    }
1409
1410    #[test]
1411    fn a_state_comment_round_trips() {
1412        let body = format!(
1413            "{STATE_MARKER}\n{}\n-->",
1414            serde_json::to_string(&state()).unwrap()
1415        );
1416        let back = parse_state_comment(&body).unwrap();
1417        assert_eq!(4, back.round);
1418        assert_eq!("codex", back.next_actor);
1419    }
1420
1421    /// It must render as nothing, so PRs are not littered with machine state.
1422    #[test]
1423    fn the_state_block_is_an_html_comment() {
1424        let body = format!(
1425            "{STATE_MARKER}\n{}\n-->",
1426            serde_json::to_string(&state()).unwrap()
1427        );
1428        assert!(body.starts_with("<!--"));
1429        assert!(body.trim_end().ends_with("-->"));
1430        assert!(!body[..body.find('{').unwrap()].contains("-->"));
1431    }
1432
1433    #[test]
1434    fn an_unrelated_json_block_is_not_state() {
1435        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
1436    }
1437
1438    #[test]
1439    fn a_malformed_state_comment_is_none_not_a_panic() {
1440        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
1441    }
1442
1443    #[test]
1444    fn atomic_write_leaves_no_temp_file() {
1445        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
1446        let _ = std::fs::remove_dir_all(&dir);
1447        let path = dir.join("state").join("pr-7.json");
1448        write_json_atomic(&path, &state()).unwrap();
1449        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
1450            .unwrap()
1451            .flatten()
1452            .filter_map(|e| e.file_name().to_str().map(str::to_string))
1453            .collect();
1454        assert_eq!(vec!["pr-7.json".to_string()], files);
1455        let _ = std::fs::remove_dir_all(&dir);
1456    }
1457
1458    #[test]
1459    fn atomic_write_overwrites_rather_than_accumulating() {
1460        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
1461        let _ = std::fs::remove_dir_all(&dir);
1462        let path = dir.join("pr-7.json");
1463        for round in 1..4 {
1464            let mut s = state();
1465            s.round = round;
1466            write_json_atomic(&path, &s).unwrap();
1467        }
1468        let back: PersistedState =
1469            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1470        assert_eq!(3, back.round);
1471        let _ = std::fs::remove_dir_all(&dir);
1472    }
1473
1474    #[test]
1475    fn style_from_env_defaults_to_enforcing() {
1476        std::env::remove_var("SPAR_BAN_EM_DASH");
1477        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
1478        let style = style_from_env();
1479        assert!(style.ban_em_dash && style.ban_ai_attribution);
1480        assert!(
1481            !style.terse,
1482            "the commit filter must not truncate a commit message"
1483        );
1484    }
1485}
1486
1487#[cfg(test)]
1488mod comment_page_tests {
1489    use super::*;
1490
1491    #[test]
1492    fn a_single_merged_array_is_read() {
1493        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
1494        assert_eq!(2, pages.len());
1495        assert_eq!(Some(2), pages[1]["id"].as_i64());
1496    }
1497
1498    #[test]
1499    fn concatenated_pages_from_an_older_gh_are_read_too() {
1500        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
1501        assert_eq!(2, pages.len());
1502    }
1503
1504    /// A comment body containing a bracket pair used to split the payload into
1505    /// two invalid halves, so no state comment was found and a resume silently
1506    /// started from round one.
1507    #[test]
1508    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
1509        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
1510        let pages = parse_comment_pages(text);
1511        assert_eq!(2, pages.len(), "{pages:?}");
1512        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
1513    }
1514
1515    #[test]
1516    fn empty_output_is_no_comments_not_a_panic() {
1517        assert!(parse_comment_pages("").is_empty());
1518        assert!(parse_comment_pages("   ").is_empty());
1519        assert!(parse_comment_pages("[]").is_empty());
1520    }
1521
1522    #[test]
1523    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
1524        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
1525    }
1526
1527    #[test]
1528    fn state_is_found_in_the_last_matching_comment() {
1529        let payload = |round: u32| {
1530            format!(
1531                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
1532            )
1533        };
1534        let text = serde_json::to_string(&serde_json::json!([
1535            {"id": 1, "body": payload(1)},
1536            {"id": 2, "body": "looks good to me"},
1537            {"id": 3, "body": payload(5)},
1538        ]))
1539        .unwrap();
1540        let pages = parse_comment_pages(&text);
1541        let last = pages
1542            .iter()
1543            .rev()
1544            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
1545            .unwrap();
1546        assert_eq!(5, last.round);
1547    }
1548}
1549
1550#[cfg(test)]
1551mod linked_pr_tests {
1552    use super::*;
1553
1554    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
1555    /// It carries an id and a whole repository object per linked issue, and a
1556    /// parser that chokes on those reports "no pull request", which is the one
1557    /// answer that makes spar implement over the top of somebody's work.
1558    const REAL_PAYLOAD: &str = r#"[
1559      {"number":14252,"title":"fix: reject leading-dash branch names",
1560       "url":"https://github.com/cli/cli/pull/14252",
1561       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
1562         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
1563         "url":"https://github.com/cli/cli/issues/14238"}]},
1564      {"number":14217,"title":"another change",
1565       "url":"https://github.com/cli/cli/pull/14217",
1566       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
1567         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
1568         "url":"https://github.com/cli/cli/issues/9761"}]},
1569      {"number":14200,"title":"unlinked work",
1570       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
1571    ]"#;
1572
1573    #[test]
1574    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
1575        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
1576        assert_eq!(14252, pr.number);
1577        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
1578    }
1579
1580    #[test]
1581    fn the_right_pr_is_picked_out_of_several() {
1582        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
1583    }
1584
1585    #[test]
1586    fn an_issue_nobody_is_working_on_finds_nothing() {
1587        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
1588    }
1589
1590    #[test]
1591    fn an_unlinked_pr_is_never_matched() {
1592        // 14200 closes nothing, so no issue number should ever return it.
1593        for issue in [14200, 0, 1] {
1594            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
1595                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
1596            }
1597        }
1598    }
1599
1600    #[test]
1601    fn empty_or_broken_output_is_none_rather_than_a_panic() {
1602        assert!(find_linked_pr("", 1).is_none());
1603        assert!(find_linked_pr("[]", 1).is_none());
1604        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
1605        assert!(find_linked_pr("[{\"number\":", 1).is_none());
1606    }
1607
1608    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
1609    #[test]
1610    fn pr_view_reads_the_cross_repository_flag() {
1611        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
1612                       "baseRefName":"main","state":"OPEN",
1613                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
1614        let pr: PrView = serde_json::from_str(json).unwrap();
1615        assert!(pr.is_cross_repository);
1616        assert!(pr.is_open());
1617
1618        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
1619        assert!(
1620            !serde_json::from_str::<PrView>(&same_repo)
1621                .unwrap()
1622                .is_cross_repository
1623        );
1624    }
1625}