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