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