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, BTreeSet};
5use std::ffi::OsStr;
6use std::fs::OpenOptions;
7use std::io::{BufRead, Read, Write};
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::sync::{Mutex, OnceLock};
11
12use serde::Deserialize;
13use serde_json::Value;
14use sha1::Sha1;
15use sha2::{Digest, Sha256};
16
17use crate::config::{Config, Drafts, Followups, StateStore};
18use crate::error::Result;
19use crate::model::{
20    Contested, ContestedItem, Followup, Issue, IssueRef, ItemKind, PersistedState, PrRef, PrRow,
21    PrView,
22};
23use crate::proc::{self, ExecOpts};
24use crate::style::{self, Style};
25use crate::textsim;
26use crate::{bail, logdim, logwarn, spar_err};
27
28/// gh returns newest first, so its `--limit` cannot be used to take the lowest
29/// numbered items: it would slice the newest N and then sorting that slice
30/// silently drops the older ones. Fetch a generous page, sort, then truncate.
31pub const FETCH_CEILING: usize = 500;
32
33/// An unclosed HTML comment on purpose. The payload is written after it and
34/// terminated with `-->`, so GitHub renders the whole block as nothing.
35pub const STATE_MARKER: &str = "<!-- spar:state";
36
37/// The signature on a comment spar composed, invisible when rendered.
38///
39/// spar posts as whoever ran it, so a comment from the viewer proves nothing
40/// about who wrote it. Without this, spar's own outcome and round comments
41/// counted as the viewer having answered, and `checkin` skipped a person's
42/// comment as "replied to since" because a bookkeeping comment happened to be
43/// newer than it.
44pub const COMMENT_MARKER: &str = "<!-- spar:comment -->";
45
46/// An entry boundary in the local follow-up note, on the same principle as
47/// `STATE_MARKER` and rendered as nothing for the same reason.
48///
49/// A follow-up's own sections are written as `## Problem` and friends, at the
50/// same heading level as the entry's title, so the file's shape does not say
51/// which of two `## ` lines starts an entry. This does. Files written before it
52/// existed are still read, by the heuristic in `followups::parse`.
53pub const FOLLOWUP_MARKER: &str = "<!-- spar:followup -->";
54
55const WORKTREE_DIR: &str = ".spar-worktrees";
56const STATE_DIR: &str = ".spar";
57
58/// How many names one part of a split may be tried on before giving up. High
59/// enough that nobody reaches it by splitting the same pull request again, low
60/// enough that a repository where every name is taken says so rather than
61/// looping.
62const SPLIT_SLOTS: u32 = 20;
63
64#[derive(Debug, Clone)]
65pub struct SplitPushError {
66    message: String,
67    retain_worktree: bool,
68}
69
70impl SplitPushError {
71    pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
72        Self {
73            message: message.into(),
74            retain_worktree,
75        }
76    }
77
78    pub fn retain_worktree(&self) -> bool {
79        self.retain_worktree
80    }
81}
82
83impl std::fmt::Display for SplitPushError {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.write_str(&self.message)
86    }
87}
88
89impl std::error::Error for SplitPushError {}
90
91/// The branch and worktree name for one part, on its `attempt`th name.
92///
93/// The unsuffixed name first, so the ordinary case reads as `split-12-1` and
94/// only a repeat split carries a suffix.
95fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
96    match attempt {
97        1 => format!("split-{parent}-{index}"),
98        n => format!("split-{parent}-{index}-{n}"),
99    }
100}
101
102#[derive(Debug)]
103pub struct Repo {
104    root: PathBuf,
105    pub style: Style,
106    pub branch_prefix: String,
107    pub state_store: StateStore,
108    pub followups: Followups,
109    pub drafts: Drafts,
110    /// The login `gh` is authenticated as, asked at most once.
111    ///
112    /// `OnceLock` rather than `OnceCell` because `&Repo` crosses a
113    /// `std::thread::scope` whenever both agents are asked at the same time,
114    /// and only `OnceLock` is `Sync`.
115    viewer: OnceLock<String>,
116    /// Highest persisted checkpoint observed for each pull request.
117    ///
118    /// Kept in memory so a transient state read cannot reset the sequence
119    /// after a resume already loaded a newer checkpoint.
120    checkpoints: Mutex<BTreeMap<i64, u64>>,
121    writes: WriteStats,
122}
123
124#[derive(Debug, Default)]
125struct WriteStats {
126    attempted: AtomicUsize,
127    failed: AtomicUsize,
128}
129
130#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
131pub(crate) struct WriteSummary {
132    pub(crate) attempted: usize,
133    pub(crate) failed: usize,
134}
135
136impl WriteSummary {
137    pub(crate) fn succeeded(self) -> usize {
138        self.attempted.saturating_sub(self.failed)
139    }
140}
141
142/// Ignored, untracked paths present before an editing call starts.
143///
144/// Existing build artifacts are deliberately part of the baseline. Callers can
145/// therefore distinguish them from an ignored file the editing call created
146/// and avoid deleting the latter as if no work had happened.
147#[derive(Debug, Clone)]
148pub(crate) struct WorktreeBaseline {
149    attributes: AttributeState,
150    ignored_untracked: IgnoredState,
151    git_state: GitState,
152}
153
154/// The recorded Git state of a worktree that must remain read only.
155///
156/// Read-only agent calls are still ordinary processes. Capturing the complete
157/// working state lets callers refuse to publish their answer or delete the
158/// checkout if a call writes despite its instructions.
159#[derive(Debug, Clone)]
160pub(crate) struct WorktreeCheckpoint {
161    path: PathBuf,
162    attributes: AttributeState,
163    git_state: GitState,
164    ignored_untracked: IgnoredState,
165}
166
167#[derive(Debug, Clone, Default, PartialEq, Eq)]
168pub(crate) struct AttributeState {
169    files: BTreeMap<PathBuf, [u8; 32]>,
170}
171
172/// Exact paths and fingerprints for every untracked file, plus ignored paths.
173///
174/// Paths stay as operating-system strings so a non-UTF-8 filename cannot be
175/// merged with another path by lossy command-output conversion.
176#[derive(Debug, Clone, Default, PartialEq, Eq)]
177pub(crate) struct IgnoredState {
178    files: BTreeMap<PathBuf, UntrackedFile>,
179    ignored: BTreeSet<PathBuf>,
180}
181
182/// A bounded-cost fingerprint for an untracked filesystem entry.
183///
184/// Content hashing every ignored compiler artifact made each checkpoint read
185/// gigabytes. File identity, type, size, timestamps, and mode detect ordinary
186/// writes without rereading build output. Unix change time also changes when a
187/// writer restores the modification time.
188#[derive(Debug, Clone, PartialEq, Eq)]
189struct UntrackedFile {
190    kind: u8,
191    len: u64,
192    modified: Option<std::time::SystemTime>,
193    created: Option<std::time::SystemTime>,
194    readonly: bool,
195    symlink_target: Option<Vec<u8>>,
196    #[cfg(unix)]
197    device: u64,
198    #[cfg(unix)]
199    inode: u64,
200    #[cfg(unix)]
201    mode: u32,
202    #[cfg(unix)]
203    change_seconds: i64,
204    #[cfg(unix)]
205    change_nanoseconds: i64,
206}
207
208#[derive(Debug, Clone, Default, PartialEq, Eq)]
209pub(crate) struct GitState {
210    repositories: BTreeMap<PathBuf, RepositoryState>,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
214struct RepositoryState {
215    head: String,
216    unsafe_index_flags: Vec<u8>,
217    tracked: BTreeMap<PathBuf, TrackedEntry>,
218    gitlinks: BTreeMap<PathBuf, String>,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
222struct TrackedEntry {
223    index_mode: String,
224    index_oid: String,
225    worktree: Option<WorktreeFile>,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
229struct WorktreeFile {
230    mode: String,
231    #[cfg(unix)]
232    permissions: u32,
233    raw_oid: String,
234    fingerprint: [u8; 32],
235    content: [u8; 32],
236}
237
238struct Gitlink {
239    path: PathBuf,
240    oid: String,
241}
242
243struct IndexEntry {
244    path: PathBuf,
245    mode: String,
246    oid: String,
247}
248
249impl IgnoredState {
250    fn is_ignored(&self, path: &Path) -> bool {
251        self.ignored.contains(path)
252    }
253
254    fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
255        let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
256        paths.extend(after.files.keys().cloned());
257        paths
258            .into_iter()
259            .filter(|path| self.files.get(path) != after.files.get(path))
260            .collect()
261    }
262
263    fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
264        self.files
265            .iter()
266            .filter(|(path, state)| after.files.get(*path) != Some(*state))
267            .map(|(path, _)| path.clone())
268            .collect()
269    }
270
271    fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
272        after
273            .files
274            .keys()
275            .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
276            .cloned()
277            .collect()
278    }
279
280    /// Whether anything moved here that is not recognized build or cache
281    /// output.
282    ///
283    /// A read-only call is held to its word by comparing the worktree before
284    /// and after. Verifying a finding usually means building the project and
285    /// running its tests, which rewrites that output, and holding a reviewer to
286    /// a byte-identical `dist/` threw away the review it had just done. What
287    /// the call was asked to judge is the tracked tree, and that is compared as
288    /// strictly as ever, along with every other untracked file.
289    pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
290        let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
291        paths.extend(after.files.keys());
292        paths.into_iter().any(|path| {
293            if self.files.get(path) == after.files.get(path)
294                && self.is_ignored(path) == after.is_ignored(path)
295            {
296                return false;
297            }
298            !(is_generated_artifact(path) && self.disposable(path) && after.disposable(path))
299        })
300    }
301
302    /// Whether this state has nothing at `path` worth keeping: either the path
303    /// is not there at all, or it is there as an ignored file.
304    fn disposable(&self, path: &Path) -> bool {
305        !self.files.contains_key(path) || self.is_ignored(path)
306    }
307}
308
309/// Build output one commit attempt let through, gathered for a single report.
310///
311/// The checks that allow it run more than once per attempt, before staging,
312/// after staging, and again after the commit, because the tree could have moved
313/// under any of them. Reporting from inside each check said the same thing
314/// about the same files two and three times over.
315#[derive(Default)]
316struct GeneratedArtifacts {
317    new_paths: BTreeSet<PathBuf>,
318    changed_paths: BTreeSet<PathBuf>,
319}
320
321impl GeneratedArtifacts {
322    fn left(&mut self, paths: Vec<PathBuf>) {
323        self.new_paths.extend(paths);
324    }
325
326    fn changed(&mut self, paths: Vec<PathBuf>) {
327        self.changed_paths.extend(paths);
328    }
329
330    /// Said once, and not as a warning. The files stay out of the commit,
331    /// whatever wrote them writes them again, and they no longer keep the
332    /// worktree from being removed.
333    fn report(&self, cwd: &Path) {
334        if !self.new_paths.is_empty() {
335            logdim!(
336                "the editing call left {} generated artifact(s) under a known build or cache \
337                 directory in {}. They are not part of the commit.",
338                self.new_paths.len(),
339                cwd.display()
340            );
341        }
342        if !self.changed_paths.is_empty() {
343            logdim!(
344                "the editing call changed {} existing generated artifact(s) under a known build \
345                 or cache directory in {}. They are not part of the commit.",
346                self.changed_paths.len(),
347                cwd.display()
348            );
349        }
350    }
351}
352
353/// Generated directories that test and build commands routinely create.
354///
355/// Files under these directories are never committed, and they do not keep a
356/// worktree that is otherwise finished, so running the requested tests neither
357/// stops the tracked change reaching review nor leaves a checkout behind.
358fn is_generated_artifact(path: &Path) -> bool {
359    const DIRECTORIES: &[&str] = &[
360        "target",
361        "dist",
362        "node_modules",
363        "__pycache__",
364        ".pytest_cache",
365        ".mypy_cache",
366        ".ruff_cache",
367        ".tox",
368        ".nox",
369        ".venv",
370        "venv",
371        ".gradle",
372        ".build",
373        "DerivedData",
374        ".next",
375        ".nuxt",
376        ".svelte-kit",
377        ".turbo",
378        "coverage",
379    ];
380    path.components().any(|component| {
381        let std::path::Component::Normal(name) = component else {
382            return false;
383        };
384        DIRECTORIES
385            .iter()
386            .any(|directory| name == OsStr::new(directory))
387    })
388}
389
390fn merge_pr_args<'a>(
391    number: &'a str,
392    expected_head: Option<&'a str>,
393    delete_branch: bool,
394) -> Vec<&'a str> {
395    let mut args = vec!["pr", "merge", number, "--squash"];
396    if delete_branch {
397        args.push("--delete-branch");
398    }
399    if let Some(expected_head) = expected_head {
400        args.extend(["--match-head-commit", expected_head]);
401    }
402    args
403}
404
405fn reconcile_pr_creation(
406    branch: &str,
407    created: Result<String>,
408    found: Result<Option<PrRef>>,
409) -> Result<PrRef> {
410    match (created, found) {
411        (_, Ok(Some(pr))) => Ok(pr),
412        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
413            "PR creation reported success but none was found for {branch}"
414        ))),
415        (Err(create), Ok(None)) => Err(spar_err!(
416            "could not open a PR for {branch}. {}",
417            create.last_line()
418        )),
419        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
420            "PR creation reported success for {branch}, but it could not be verified. {}",
421            check.last_line()
422        ))),
423        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
424            "could not open a PR for {branch}. {} The result could not be verified: {}",
425            create.last_line(),
426            check.last_line()
427        ))),
428    }
429}
430
431fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
432    #[derive(Deserialize)]
433    #[serde(rename_all = "camelCase")]
434    struct Row {
435        number: i64,
436        #[serde(default)]
437        url: String,
438        #[serde(default)]
439        title: String,
440        base_ref_name: String,
441    }
442
443    let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
444        spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
445    })?;
446    Ok(rows
447        .into_iter()
448        .find(|row| row.base_ref_name == base)
449        .map(|row| PrRef {
450            number: row.number,
451            url: row.url,
452            title: row.title,
453        }))
454}
455
456/// Sign a comment spar composed, so it can be told from one the same account
457/// wrote by hand. Idempotent: a body already signed is returned as it is.
458fn signed(body: &str) -> String {
459    if body.contains(COMMENT_MARKER) {
460        return body.to_string();
461    }
462    format!("{body}\n\n{COMMENT_MARKER}")
463}
464
465/// Whether a comment body is one spar composed rather than one a person wrote.
466pub fn is_spar_comment(body: &str) -> bool {
467    body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
468}
469
470fn has_exact_comment(comments: &[Value], body: &str) -> bool {
471    comments.iter().any(|comment| {
472        comment
473            .get("body")
474            .and_then(Value::as_str)
475            .is_some_and(|seen| seen == body)
476    })
477}
478
479fn reconcile_comment_post(
480    number: i64,
481    body: &str,
482    post_error: crate::error::SparError,
483    comments: Result<Vec<Value>>,
484) -> Result<()> {
485    match comments {
486        Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
487        Ok(_) => Err(post_error),
488        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
489            "could not comment on #{number}. {} The result could not be verified: {}",
490            post_error.last_line(),
491            read_error.last_line()
492        ))),
493    }
494}
495
496fn reconcile_issue_edit(
497    number: i64,
498    wanted: &str,
499    edit_error: crate::error::SparError,
500    observed: Result<String>,
501) -> Result<()> {
502    match observed {
503        Ok(body) if body == wanted => Ok(()),
504        Ok(_) => Err(spar_err!(
505            "could not rewrite the body of #{number}. {}",
506            edit_error.last_line()
507        )),
508        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
509            "could not rewrite the body of #{number}. {} The result could not be verified: {}",
510            edit_error.last_line(),
511            read_error.last_line()
512        ))),
513    }
514}
515
516fn issue_url_has_number(url: &str) -> bool {
517    url.trim()
518        .rsplit('/')
519        .next()
520        .and_then(|tail| tail.parse::<i64>().ok())
521        .is_some_and(|number| number > 0)
522}
523
524fn reconcile_issue_creation(
525    title: &str,
526    created: Result<String>,
527    found: Result<Option<ExistingIssue>>,
528) -> Result<String> {
529    match (created, found) {
530        (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
531        (_, Ok(Some(issue))) => Ok(issue.url),
532        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
533            "issue creation reported success but no matching issue was found for {title:?}"
534        ))),
535        (Err(create), Ok(None)) => Err(spar_err!(
536            "could not file issue {title:?}. {}",
537            create.last_line()
538        )),
539        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
540            "issue creation reported success for {title:?}, but it could not be verified. {}",
541            check.last_line()
542        ))),
543        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
544            "could not file issue {title:?}. {} The result could not be verified: {}",
545            create.last_line(),
546            check.last_line()
547        ))),
548    }
549}
550
551fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
552    if output.trim().is_empty() {
553        return Ok(None);
554    }
555    for line in output.lines() {
556        let mut fields = line.split_whitespace();
557        let oid = fields.next().unwrap_or_default();
558        let name = fields.next().unwrap_or_default();
559        if name == remote_ref && !oid.is_empty() {
560            return Ok(Some(oid.to_string()));
561        }
562    }
563    Err(spar_err!(
564        "origin returned an unexpected ref listing for {remote_ref}"
565    ))
566}
567
568fn reconcile_failed_split_push(
569    branch: &str,
570    push_error: crate::error::SparError,
571    local: Result<String>,
572    remote: Result<String>,
573) -> std::result::Result<(), SplitPushError> {
574    let remote_ref = format!("refs/heads/{branch}");
575    match (local, remote) {
576        (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
577            Ok(Some(oid)) if oid == local.trim() => Ok(()),
578            Ok(_) => Err(SplitPushError::new(
579                format!(
580                    "could not create origin/{branch}. {} The remote branch is absent or points \
581                     somewhere else. Nothing was overwritten.",
582                    push_error.last_line()
583                ),
584                false,
585            )),
586            Err(check) => Err(SplitPushError::new(
587                format!(
588                    "could not confirm whether origin/{branch} was created. {} The remote result \
589                     could not be verified: {}",
590                    push_error.last_line(),
591                    check.last_line()
592                ),
593                true,
594            )),
595        },
596        (local, remote) => {
597            let check = match (local, remote) {
598                (Err(local), Err(remote)) => format!(
599                    "the local commit could not be read: {}; origin could not be read: {}",
600                    local.last_line(),
601                    remote.last_line()
602                ),
603                (Err(local), _) => {
604                    format!("the local commit could not be read: {}", local.last_line())
605                }
606                (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
607                _ => unreachable!(),
608            };
609            Err(SplitPushError::new(
610                format!(
611                    "could not confirm whether origin/{branch} was created. {} The result could \
612                     not be verified because {check}",
613                    push_error.last_line()
614                ),
615                true,
616            ))
617        }
618    }
619}
620
621impl Repo {
622    pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
623        let root =
624            std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
625        // A linked worktree has a `.git` file rather than a directory, and a
626        // bare-ish layout can have neither, so ask git instead of guessing.
627        let inside = proc::run_str(
628            &["git", "rev-parse", "--is-inside-work-tree"],
629            &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
630        )
631        .unwrap_or_default();
632        if inside.trim() != "true" {
633            bail!("not a git repository: {}", root.display());
634        }
635        let repo = Self {
636            root,
637            style: cfg.style.clone(),
638            branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
639            state_store: cfg.loop_cfg.state_store,
640            followups: cfg.loop_cfg.followups,
641            drafts: cfg.loop_cfg.drafts,
642            viewer: OnceLock::new(),
643            checkpoints: Mutex::new(BTreeMap::new()),
644            writes: WriteStats::default(),
645        };
646        repo.self_exclude();
647        Ok(repo)
648    }
649
650    /// Keep spar's own scratch directories out of the target repo's
651    /// `git status`.
652    ///
653    /// Written to `.git/info/exclude`, never to a tracked `.gitignore`: this is
654    /// somebody else's repository and spar has no business committing to it.
655    /// Best effort and silent on failure, because a read-only git directory is
656    /// not a reason to abandon a run.
657    fn self_exclude(&self) {
658        let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
659        let git_dir = git_dir.trim();
660        if git_dir.is_empty() {
661            return;
662        }
663        let path = Path::new(git_dir).join("info").join("exclude");
664        let existing = std::fs::read_to_string(&path).unwrap_or_default();
665
666        let wanted = [format!("/{WORKTREE_DIR}/"), format!("/{STATE_DIR}/")];
667        let missing: Vec<&String> = wanted
668            .iter()
669            .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
670            .collect();
671        if missing.is_empty() {
672            return;
673        }
674
675        use std::io::Write;
676        if let Some(parent) = path.parent() {
677            let _ = std::fs::create_dir_all(parent);
678        }
679        let mut block = String::new();
680        if !existing.is_empty() && !existing.ends_with('\n') {
681            block.push('\n');
682        }
683        block.push_str("\n# added by spar: its worktrees and run state\n");
684        for line in missing {
685            block.push_str(line);
686            block.push('\n');
687        }
688        if let Ok(mut file) = std::fs::OpenOptions::new()
689            .create(true)
690            .append(true)
691            .open(&path)
692        {
693            let _ = file.write_all(block.as_bytes());
694        }
695    }
696
697    pub fn root(&self) -> &Path {
698        &self.root
699    }
700
701    pub(crate) fn write_summary(&self) -> WriteSummary {
702        WriteSummary {
703            attempted: self.writes.attempted.load(Ordering::Relaxed),
704            failed: self.writes.failed.load(Ordering::Relaxed),
705        }
706    }
707
708    pub(crate) fn record_write<T, E>(
709        &self,
710        result: std::result::Result<T, E>,
711    ) -> std::result::Result<T, E> {
712        self.record_write_outcome(result.is_err());
713        result
714    }
715
716    pub(crate) fn record_failed_write<T, E>(
717        &self,
718        result: std::result::Result<T, E>,
719    ) -> std::result::Result<T, E> {
720        if result.is_err() {
721            self.record_write_outcome(true);
722        }
723        result
724    }
725
726    fn record_write_outcome(&self, failed: bool) {
727        self.writes.attempted.fetch_add(1, Ordering::Relaxed);
728        if failed {
729            self.writes.failed.fetch_add(1, Ordering::Relaxed);
730        }
731    }
732
733    // -- gates ------------------------------------------------------------
734
735    /// Scrub, then verify. A leak here reaches GitHub, so it is a hard error
736    /// rather than a warning: silent partial compliance is how a style rule
737    /// erodes over a long run.
738    pub fn clean(&self, text: &str) -> Result<String> {
739        let out = style::scrub(text, &self.style);
740        let bad = style::violations(&out, &self.style);
741        if !bad.is_empty() {
742            bail!(
743                "style gate could not clean text ({}): {}",
744                bad.join(", "),
745                style::clip(&out, 300)
746            );
747        }
748        Ok(out)
749    }
750
751    /// Clean, and hold to a length budget. For anything a model wrote.
752    pub fn clean_body(&self, text: &str) -> Result<String> {
753        self.clean(&style::body(text, &self.style))
754    }
755
756    /// The same, with an issue's far larger budget and its exemption for code.
757    pub fn clean_issue_body(&self, text: &str) -> Result<String> {
758        self.clean(&style::issue_body(text, &self.style))
759    }
760
761    /// The single transform every outbound title goes through.
762    ///
763    /// Scrub first, clip second, and never the other way round. Clipping first
764    /// lets the scrub lengthen the result past the budget (an em dash becomes
765    /// two characters), so a second pass would clip again and produce a
766    /// different string. That broke follow-up deduplication silently: the
767    /// lookup searched for one title while GitHub had stored another, no match
768    /// was ever found, and a fresh duplicate issue was filed every review
769    /// round. Doing it in this order makes the transform idempotent, which the
770    /// tests assert.
771    pub fn clean_title(&self, text: &str) -> Result<String> {
772        Ok(style::title(&self.clean(text)?, &self.style))
773    }
774
775    pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
776        let title = self.record_failed_write(self.clean_title(text))?;
777        if title.trim().is_empty() {
778            return self.record_failed_write(Err(spar_err!(
779                "nothing left of the title after cleaning it"
780            )));
781        }
782        Ok(title)
783    }
784
785    pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
786        if self.followups == Followups::Issues {
787            self.clean_nonempty_title_for_write(text)
788        } else {
789            self.clean_title(text)
790        }
791    }
792
793    // -- git --------------------------------------------------------------
794
795    fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
796        ExecOpts::new()
797            .cwd(cwd.unwrap_or(&self.root))
798            .check(check)
799            .timeout_secs(600)
800    }
801
802    pub fn git(&self, args: &[&str]) -> Result<String> {
803        self.git_at(None, args)
804    }
805
806    pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
807        let argv = git_without_maintenance_argv(args);
808        proc::run(&argv, &self.git_opts(cwd, true))
809    }
810
811    /// Run a parent-side Git operation without inherited background helpers.
812    ///
813    /// Editing calls are untrusted. Once one returns, status, staging, and
814    /// committing happen in this process, so an inherited fsmonitor or automatic
815    /// maintenance command must not become a way to execute outside its sandbox.
816    fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
817        let argv = git_without_automation_argv(args);
818        proc::run(
819            &argv,
820            &self.git_opts(Some(cwd), true).stop_descendants(true),
821        )
822    }
823
824    fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
825        let argv = git_without_automation_argv(args);
826        proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
827            .map(|output| output.ok())
828    }
829
830    /// Run git, tolerating failure. Returns whatever landed on stdout.
831    pub fn git_try(&self, args: &[&str]) -> String {
832        self.git_try_at(None, args)
833    }
834
835    pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
836        let argv = git_without_maintenance_argv(args);
837        proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
838    }
839
840    /// The base branch the remote actually points at, rather than assuming
841    /// `main`. Falls back to the configured value when there is no origin.
842    pub fn default_branch(&self, configured: &str) -> String {
843        let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
844        match refname.trim().rsplit('/').next() {
845            Some(name) if !name.is_empty() => name.to_string(),
846            _ => configured.to_string(),
847        }
848    }
849
850    // -- branch naming and ownership --------------------------------------
851    //
852    // Branch names default to `issue-N`, which is exactly what a person would
853    // name a branch by hand. Ownership therefore cannot be inferred from the
854    // name, so every branch spar creates is recorded and cleanup only ever
855    // touches what is in that record.
856
857    pub fn branch_for_issue(&self, issue: i64) -> String {
858        format!("{}issue-{issue}", self.branch_prefix)
859    }
860
861    pub fn branch_for_pr(&self, number: i64) -> String {
862        format!("{}pr-{number}", self.branch_prefix)
863    }
864
865    /// One part of a split, numbered from 1 within its parent.
866    ///
867    /// Its own namespace rather than `issue-N`, because the parts of a split
868    /// pull request have no issue of their own and would otherwise collide with
869    /// the branch of the issue that happens to share the parent's number.
870    ///
871    /// The name a part is tried on first. `worktree_for_split` may end up on a
872    /// suffixed one, because this name is not free forever.
873    pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
874        format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
875    }
876
877    fn ledger_path(&self) -> PathBuf {
878        self.root.join(STATE_DIR).join("branches.json")
879    }
880
881    pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
882        std::fs::read_to_string(self.ledger_path())
883            .ok()
884            .and_then(|text| serde_json::from_str(&text).ok())
885            .unwrap_or_default()
886    }
887
888    pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
889        let mut data = self.known_branches();
890        data.insert(
891            branch.to_string(),
892            BranchRecord {
893                kind: kind.to_string(),
894                number,
895            },
896        );
897        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
898            logdim!("could not record branch {branch}: {e}");
899        }
900    }
901
902    pub fn forget_branch(&self, branch: &str) {
903        let mut data = self.known_branches();
904        if data.remove(branch).is_none() {
905            return;
906        }
907        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
908            logdim!("could not update the branch record: {e}");
909        }
910    }
911
912    // -- worktrees --------------------------------------------------------
913
914    fn worktree_path(&self, name: &str) -> PathBuf {
915        self.root.join(WORKTREE_DIR).join(name)
916    }
917
918    /// Isolate an issue so a failed run cannot poison the next one's base.
919    pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
920        let branch = self.branch_for_issue(issue);
921        let path = self.worktree_path(&format!("issue-{issue}"));
922
923        self.refuse_issue_branch_rebuild(issue, base)?;
924        self.refuse_dirty_worktree(&path, &format!("worktree for issue #{issue}"))?;
925
926        if !self.branch_deletion_is_safe(&branch)? {
927            bail!(
928                "the existing branch {branch} has a tip or reflog-only commit that no surviving \
929                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
930                 before retrying."
931            );
932        }
933
934        if !self.remove_worktree_at(&path)? {
935            bail!(
936                "the existing worktree for issue #{issue} could not be removed safely. Its \
937                 branch was kept."
938            );
939        }
940        if !self.delete_branch_if_safe(&branch)? {
941            bail!(
942                "the existing branch {branch} changed or remained checked out while its \
943                 worktree was being rebuilt. It was kept."
944            );
945        }
946
947        if let Some(parent) = path.parent() {
948            std::fs::create_dir_all(parent)
949                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
950        }
951
952        let path_str = path.display().to_string();
953        let remote_start = format!("origin/{base}");
954        let created = self
955            .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
956            .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
957
958        // Recorded on both paths: an unrecorded branch is one cleanup will
959        // never remove, and the fallback creates a branch just the same.
960        created.map_err(|e| {
961            spar_err!(
962                "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
963                 and does `origin` exist?",
964                e.last_line()
965            )
966        })?;
967        self.record_branch(&branch, "issue", issue);
968        Ok((path, branch))
969    }
970
971    /// Refuse to reset the local or remote branch assigned to an issue when it
972    /// carries work that no pull request preserves.
973    ///
974    /// Both linked-worktree mode and shared-checkout mode rebuild the same
975    /// branch name. Keeping the guard here prevents either path from silently
976    /// replacing recovery commits left by an earlier run.
977    pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
978        let branch = self.branch_for_issue(issue);
979        let base_remote_ref = format!("refs/heads/{base}");
980        let base_tracking_ref = format!("refs/remotes/origin/{base}");
981        let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
982        self.git(&["fetch", "--no-tags", "origin", &base_refspec])
983            .map_err(|e| {
984                spar_err!(
985                    "could not refresh origin/{base} before checking issue #{issue}: {}",
986                    e.last_line()
987                )
988            })?;
989
990        if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
991            let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
992            if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
993                bail!(
994                    "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
995                     pull request accounts for them. Rebuilding it would force push over that \
996                     work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
997                     it, or delete it with `git push origin --delete {branch}` if the remote \
998                     branch is no longer needed."
999                );
1000            }
1001        }
1002
1003        let local_ref = format!("refs/heads/{branch}");
1004        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1005            let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
1006            let recorded_pr = self
1007                .known_branches()
1008                .get(&branch)
1009                .is_some_and(|record| record.kind == "pr");
1010            let preserved = ahead == 0
1011                || if recorded_pr {
1012                    self.local_branch_is_preserved(&branch)?
1013                } else {
1014                    self.pull_request_holds(&branch, &local_ref, base)
1015                };
1016            if !preserved {
1017                let listed = self
1018                    .commit_lines(&self.root, &local_ref, base)
1019                    .iter()
1020                    .map(|line| format!("  {line}"))
1021                    .collect::<Vec<_>>()
1022                    .join("\n");
1023                bail!(
1024                    "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
1025                     no pull request preserves them. Rebuilding it would delete the only copy.\n\
1026                     {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
1027                     it, or delete it with `git branch -D {branch}` if it is stale."
1028                );
1029            }
1030        }
1031        Ok(())
1032    }
1033
1034    fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
1035        let live_ref = format!("refs/heads/{branch}");
1036        let tracking_ref = format!("refs/remotes/origin/{branch}");
1037        let listed = self
1038            .git(&["ls-remote", "--heads", "origin", &live_ref])
1039            .map_err(|e| {
1040                spar_err!(
1041                    "could not verify whether origin/{branch} still exists: {}",
1042                    e.last_line()
1043                )
1044            })?;
1045
1046        if remote_head_oid(&listed, &live_ref)?.is_some() {
1047            let refspec = format!("+{live_ref}:{tracking_ref}");
1048            self.git(&["fetch", "--no-tags", "origin", &refspec])
1049                .map_err(|e| {
1050                    spar_err!(
1051                        "origin/{branch} exists but its tracking ref could not be refreshed: {}",
1052                        e.last_line()
1053                    )
1054                })?;
1055            if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1056                bail!("origin/{branch} was fetched but its tracking ref is missing");
1057            }
1058            return Ok(Some(tracking_ref));
1059        }
1060
1061        if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1062            return Ok(None);
1063        }
1064        let expected = self
1065            .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
1066            .trim()
1067            .to_string();
1068        self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
1069            .map_err(|e| {
1070                spar_err!(
1071                    "could not discard stale origin/{branch} tracking ref safely: {}",
1072                    e.last_line()
1073                )
1074            })?;
1075        if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1076            bail!(
1077                "origin/{branch} changed while its stale tracking ref was being removed. It was \
1078                 kept."
1079            );
1080        }
1081        Ok(None)
1082    }
1083
1084    /// Whether a pull request from `branch` already holds every commit `refname`
1085    /// has beyond `base`.
1086    ///
1087    /// GitHub serves `refs/pull/N/head` for as long as the repository lives, so
1088    /// commits that reached a pull request outlive the branch they were pushed
1089    /// from. A matching branch name does not establish that on its own: an
1090    /// issue worked twice reuses the name, and the merged pull request from the
1091    /// first round says nothing about where the second round's commits are.
1092    fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
1093        self.prs_for_branch(branch)
1094            .iter()
1095            .any(|pr| self.pr_head_holds(pr.number, refname, base))
1096    }
1097
1098    fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
1099        let head = format!("refs/spar/pr-head/{number}");
1100        let refspec = format!("+refs/pull/{number}/head:{head}");
1101        if self.git(&["fetch", "origin", &refspec]).is_err() {
1102            return false;
1103        }
1104        let held = self.commits_held_by(refname, base, &head);
1105        self.git_try(&["update-ref", "-d", &head]);
1106        held
1107    }
1108
1109    pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
1110        let argv = vec![
1111            "git".to_string(),
1112            "merge-base".to_string(),
1113            "--is-ancestor".to_string(),
1114            older.to_string(),
1115            newer.to_string(),
1116        ];
1117        let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
1118        match out.code {
1119            0 => Ok(true),
1120            1 => Ok(false),
1121            _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
1122        }
1123    }
1124
1125    fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
1126        let head = format!("refs/spar/pr-head/{number}");
1127        let refspec = format!("+refs/pull/{number}/head:{head}");
1128        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1129            spar_err!(
1130                "could not verify the immutable head of PR #{number}: {}",
1131                e.last_line()
1132            )
1133        })?;
1134        let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
1135        self.git_try(&["update-ref", "-d", &head]);
1136        held
1137    }
1138
1139    fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
1140        let text = self.gh(&[
1141            "pr",
1142            "list",
1143            "--head",
1144            branch,
1145            "--state",
1146            "all",
1147            "--json",
1148            "number,url,title",
1149        ])?;
1150        serde_json::from_str(text.trim())
1151            .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
1152    }
1153
1154    fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
1155        let branch_ref = format!("refs/heads/{branch}");
1156        if record.kind == "pr" {
1157            return self.pr_head_contains_checked(record.number, &branch_ref);
1158        }
1159        let prs = self.branch_prs_checked(branch)?;
1160        if prs.is_empty() {
1161            return Ok(false);
1162        }
1163        for pr in prs {
1164            if self.pr_head_contains_checked(pr.number, &branch_ref)? {
1165                return Ok(true);
1166            }
1167        }
1168        Ok(false)
1169    }
1170
1171    fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
1172        let local_ref = format!("refs/heads/{branch}");
1173        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1174            return Ok(true);
1175        }
1176        let oid = self
1177            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1178            .trim()
1179            .to_string();
1180        let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
1181        if !durable_tip {
1182            let remote_ref = format!("refs/heads/{branch}");
1183            let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
1184            durable_tip = remote.lines().any(|line| {
1185                line.split_whitespace()
1186                    .next()
1187                    .is_some_and(|remote_oid| remote_oid == oid)
1188            });
1189        }
1190        if !durable_tip {
1191            if let Some(record) = self.known_branches().get(branch) {
1192                durable_tip = self.branch_is_preserved_checked(branch, record)?;
1193            }
1194        }
1195        if !durable_tip {
1196            return Ok(false);
1197        }
1198        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1199    }
1200
1201    /// Delete a branch only if its exact current tip and reflog are still safe.
1202    /// The expected old value makes a concurrent ref update fail instead of
1203    /// deleting work that appeared after the preservation check.
1204    fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
1205        let local_ref = format!("refs/heads/{branch}");
1206        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1207            return Ok(true);
1208        }
1209        let expected = self
1210            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1211            .trim()
1212            .to_string();
1213        if !self.branch_deletion_is_safe(branch)? {
1214            return Ok(false);
1215        }
1216        let checked_out = self
1217            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
1218            .lines()
1219            .any(|line| line == format!("branch {local_ref}"));
1220        if checked_out {
1221            return Ok(false);
1222        }
1223        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1224        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1225    }
1226
1227    fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
1228        let local_ref = review_ref(number);
1229        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1230            return Ok(true);
1231        }
1232        let oid = self
1233            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1234            .trim()
1235            .to_string();
1236        if !self.pr_head_contains_checked(number, &local_ref)? {
1237            return Ok(false);
1238        }
1239        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1240    }
1241
1242    fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
1243        let local_ref = review_ref(number);
1244        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1245            return Ok(true);
1246        }
1247        let expected = self
1248            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1249            .trim()
1250            .to_string();
1251        if !self.review_ref_deletion_is_safe(number)? {
1252            return Ok(false);
1253        }
1254        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1255        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1256    }
1257
1258    /// Whether `other` already contains every commit `branch` has beyond
1259    /// `base`. False when either ref fails to resolve, so a ref that is not
1260    /// there cannot vouch for anything.
1261    pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
1262        let range = format!("{}..{branch}", self.base_ref(&self.root, base));
1263        self.git_try(&["rev-list", "--count", &range, "--not", other])
1264            .trim()
1265            == "0"
1266    }
1267
1268    pub fn worktree_remove(&self, issue: i64) -> bool {
1269        let path = self.worktree_path(&format!("issue-{issue}"));
1270        match self.remove_worktree_at(&path) {
1271            Ok(removed) => removed,
1272            Err(error) => {
1273                logdim!(
1274                    "kept {} because removal did not reach a confirmed quiet point: {}",
1275                    path.display(),
1276                    error.last_line()
1277                );
1278                false
1279            }
1280        }
1281    }
1282
1283    /// Verify both the common Git directory and the worktree top level.
1284    ///
1285    /// A stale worktree entry is not ownership proof. An unrelated repository
1286    /// can later occupy the same path and must survive cleanup.
1287    fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
1288        let wanted = std::fs::canonicalize(path)
1289            .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
1290        // Every SPAR worktree path is built from the canonical repository root.
1291        // A different canonical path therefore means the final component or
1292        // one of its parents is a symlink. Passing that alias to `git worktree
1293        // remove` can delete the worktree at its real target.
1294        if wanted != path {
1295            return Ok(false);
1296        }
1297        let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
1298            let raw = PathBuf::from(value.trim());
1299            let joined = if raw.is_absolute() {
1300                raw
1301            } else {
1302                cwd.join(raw)
1303            };
1304            std::fs::canonicalize(&joined)
1305                .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
1306        };
1307        let expected =
1308            self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
1309        let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
1310        let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
1311        let expected = resolve(&self.root, &expected)?;
1312        let actual = resolve(path, &actual)?;
1313        let top = resolve(path, &top)?;
1314        Ok(expected == actual && top == wanted)
1315    }
1316
1317    /// Remove only a worktree that belongs to this repository.
1318    ///
1319    /// The path sits under a predictable directory, but that does not establish
1320    /// ownership. A clean independent repository at the same path must survive
1321    /// even when `git worktree remove` rejects it.
1322    fn remove_worktree_at_with_force(&self, path: &Path, force: bool) -> Result<bool> {
1323        let existed = path.exists();
1324        if path.exists() {
1325            match self.worktree_belongs_to_repo(path) {
1326                Ok(true) => {}
1327                Ok(false) => {
1328                    logdim!(
1329                        "kept {} because it is not a worktree owned by this repository",
1330                        path.display()
1331                    );
1332                    return Ok(false);
1333                }
1334                Err(e) => {
1335                    logdim!(
1336                        "kept {} because its worktree ownership could not be verified: {}",
1337                        path.display(),
1338                        e.last_line()
1339                    );
1340                    return Ok(false);
1341                }
1342            }
1343            if !force {
1344                match self.has_recoverable_work(path) {
1345                    Ok(true) => {
1346                        logdim!(
1347                            "kept {} because it contains recoverable files or repository state",
1348                            path.display()
1349                        );
1350                        return Ok(false);
1351                    }
1352                    Err(e) => {
1353                        logdim!(
1354                            "kept {} because its recoverable state could not be checked: {}",
1355                            path.display(),
1356                            e.last_line()
1357                        );
1358                        return Ok(false);
1359                    }
1360                    Ok(false) => {}
1361                }
1362            }
1363        }
1364        let path_str = path.display().to_string();
1365        let command_ok = if force {
1366            self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
1367        } else {
1368            self.git_try_without_automation(&["worktree", "remove", &path_str])?
1369        };
1370        Ok((command_ok || !existed) && !path.exists())
1371    }
1372
1373    fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
1374        self.remove_worktree_at_with_force(path, false)
1375    }
1376
1377    /// Force removal is reserved for the explicit `clean --all` path.
1378    fn remove_worktree_at_force(&self, path: &Path) -> bool {
1379        match self.remove_worktree_at_with_force(path, true) {
1380            Ok(removed) => removed,
1381            Err(error) => {
1382                logdim!(
1383                    "kept {} because removal did not reach a confirmed quiet point: {}",
1384                    path.display(),
1385                    error.last_line()
1386                );
1387                false
1388            }
1389        }
1390    }
1391
1392    /// Remove a worktree only after a caller has verified it is unchanged.
1393    ///
1394    /// There is deliberately no force fallback, so Git can still refuse a
1395    /// removal if tracked or non-ignored work appears after the final check.
1396    fn remove_worktree_at_checked(&self, path: &Path) -> Result<bool> {
1397        if path.exists() && !self.worktree_belongs_to_repo(path)? {
1398            bail!(
1399                "{} is not a worktree owned by this repository, so it was kept",
1400                path.display()
1401            );
1402        }
1403        if path.exists() && self.has_recoverable_work(path)? {
1404            bail!(
1405                "the verified worktree at {} contains recoverable files or repository state. It \
1406                 was kept.",
1407                path.display()
1408            );
1409        }
1410        let path_str = path.display().to_string();
1411        self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
1412            .map_err(|e| {
1413                e.with_message(format!(
1414                    "could not remove the verified worktree at {}: {}. It was kept.",
1415                    path.display(),
1416                    e.last_line()
1417                ))
1418            })?;
1419        Ok(!path.exists())
1420    }
1421
1422    fn refuse_dirty_worktree(&self, path: &Path, label: &str) -> Result<()> {
1423        if !path.is_dir() {
1424            return Ok(());
1425        }
1426        let has_files = std::fs::read_dir(path)
1427            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
1428            .next()
1429            .is_some();
1430        let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
1431            spar_err!(
1432                "could not verify whether the existing {label} at {} belongs to this repository, \
1433                 so it was kept: {}",
1434                path.display(),
1435                e.last_line()
1436            )
1437        })?;
1438        if !owned {
1439            if has_files {
1440                bail!(
1441                    "the existing {label} at {} is not a worktree owned by \
1442                     this repository. Refusing to remove it.",
1443                    path.display()
1444                );
1445            }
1446            return Ok(());
1447        }
1448        if !path.join(".git").exists() {
1449            if has_files {
1450                bail!(
1451                    "the existing {label} at {} is not a readable Git worktree and is not empty. \
1452                     Refusing to remove it.",
1453                    path.display()
1454                );
1455            }
1456            return Ok(());
1457        }
1458        let dirty = self.has_recoverable_work(path).map_err(|e| {
1459            spar_err!(
1460                "could not verify whether the existing {label} at {} is clean, so it was kept: \
1461                 {}",
1462                path.display(),
1463                e.last_line()
1464            )
1465        })?;
1466        if dirty {
1467            bail!(
1468                "the existing {label} contains uncommitted changes or ignored files at {}. \
1469                 Rebuilding it would delete those files.\nCommit or recover them before running this \
1470                 command again, or use `spar clean --all` if they are not needed.",
1471                path.display()
1472            );
1473        }
1474        Ok(())
1475    }
1476
1477    /// Check an existing PR branch out into an isolated worktree.
1478    pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
1479        let head = pr.head_ref_name.clone();
1480        if head.trim().is_empty() {
1481            bail!("PR #{} has no head branch to check out", pr.number);
1482        }
1483        let path = self.worktree_path(&format!("pr-{}", pr.number));
1484        let local = self.branch_for_pr(pr.number);
1485
1486        self.git(&["fetch", "origin", &head]).map_err(|e| {
1487            spar_err!(
1488                "could not fetch the branch behind PR #{}: {}",
1489                pr.number,
1490                e.last_line()
1491            )
1492        })?;
1493        let start = format!("origin/{head}");
1494        let start_ref = format!("refs/remotes/origin/{head}");
1495        let local_ref = format!("refs/heads/{local}");
1496        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1497            let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
1498            if unpushed > 0 {
1499                bail!(
1500                    "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
1501                     on {start}. Rebuilding it would delete their branch.\nInspect the worktree at \
1502                     {} and push or recover those commits before running this command again.",
1503                    pr.number,
1504                    path.display()
1505                );
1506            }
1507        }
1508        self.refuse_dirty_worktree(&path, &format!("worktree for PR #{}", pr.number))?;
1509        if !self.branch_deletion_is_safe(&local)? {
1510            bail!(
1511                "the existing branch {local} has a tip or reflog-only commit that no surviving \
1512                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1513                 before retrying."
1514            );
1515        }
1516        if !self.remove_worktree_at(&path)? {
1517            bail!(
1518                "the existing worktree for PR #{} could not be removed safely. Its branch was \
1519                 kept.",
1520                pr.number
1521            );
1522        }
1523        if !self.delete_branch_if_safe(&local)? {
1524            bail!(
1525                "the existing branch {local} changed or remained checked out while the PR \
1526                 worktree was being rebuilt. It was kept."
1527            );
1528        }
1529
1530        let path_str = path.display().to_string();
1531        self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
1532        self.record_branch(&local, "pr", pr.number);
1533        Ok((path, head))
1534    }
1535
1536    /// Check a pull request's head out read only, detached, with no branch.
1537    ///
1538    /// Fetches `refs/pull/N/head`, which GitHub serves for every pull request
1539    /// including one from a fork whose branch is not in this repository at all.
1540    /// That is what makes reviewing an outside contribution possible when
1541    /// pushing to it is not.
1542    ///
1543    /// Detached on purpose. Review only mode has nothing to push, and a branch
1544    /// would only invite something to try.
1545    pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
1546        let path = self.worktree_path(&format!("review-{number}"));
1547        let local_ref = review_ref(number);
1548        let refspec = format!("+refs/pull/{number}/head:{local_ref}");
1549
1550        self.refuse_review_worktree_changes(number)?;
1551
1552        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1553            spar_err!(
1554                "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
1555                 every pull request, so this usually means the number is wrong or `origin` does \
1556                 not point at the repository the PR is on.",
1557                e.last_line()
1558            )
1559        })?;
1560
1561        if let Some(parent) = path.parent() {
1562            std::fs::create_dir_all(parent)
1563                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1564        }
1565        if !self.remove_worktree_at(&path)? {
1566            bail!(
1567                "the existing review worktree for PR #{number} could not be removed safely. Its \
1568                 reference was kept."
1569            );
1570        }
1571        let path_str = path.display().to_string();
1572        self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
1573        Ok(path)
1574    }
1575
1576    fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
1577        let path = self.worktree_path(&format!("review-{number}"));
1578        if !path.is_dir() {
1579            return Ok(());
1580        }
1581        let local_ref = review_ref(number);
1582        if !self.worktree_belongs_to_repo(&path)? {
1583            return Ok(());
1584        }
1585        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1586            bail!(
1587                "the existing review worktree for PR #{number} has no recorded head at \
1588                 {local_ref}. Refusing to rebuild {}.",
1589                path.display()
1590            );
1591        }
1592        let worktree_head = self.head_oid_checked(&path)?;
1593        let recorded_head = self
1594            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1595            .trim()
1596            .to_string();
1597        if worktree_head != recorded_head {
1598            bail!(
1599                "the existing review worktree for PR #{number} has a local commit that is not on \
1600                 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
1601                 {} before retrying.",
1602                path.display()
1603            );
1604        }
1605        self.refuse_dirty_worktree(&path, &format!("review worktree for PR #{number}"))?;
1606        Ok(())
1607    }
1608
1609    /// A worktree for one part of a split, on a new branch off `start`.
1610    ///
1611    /// `start` is the base branch for independent parts and the previous part's
1612    /// branch for stacked ones, which is the only difference between the two
1613    /// shapes at this level.
1614    ///
1615    /// The branch is whatever name was free, which is why it is returned rather
1616    /// than derived by the caller. Splitting the same pull request a second
1617    /// time would otherwise target the branch behind the first run's pull
1618    /// request. Split pushes are create-only and would refuse that target, but
1619    /// a repeated split still needs distinct branches rather than a name that
1620    /// can never be created.
1621    pub fn worktree_for_split(
1622        &self,
1623        parent: i64,
1624        index: usize,
1625        start: &str,
1626    ) -> Result<(PathBuf, String)> {
1627        let slot = self.free_split_slot(parent, index)?;
1628        let branch = format!("{}{slot}", self.branch_prefix);
1629        let path = self.worktree_path(&slot);
1630
1631        if let Some(dir) = path.parent() {
1632            std::fs::create_dir_all(dir)
1633                .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
1634        }
1635        self.refuse_dirty_worktree(&path, &format!("worktree for part {index} of PR #{parent}"))?;
1636        // The name is free, so there is no branch to delete. A directory can
1637        // still be in the way, left by a worktree that was pruned from git's
1638        // records without being removed from disk.
1639        if !self.remove_worktree_at(&path)? {
1640            bail!(
1641                "the existing worktree for part {index} of PR #{parent} could not be removed \
1642                 safely. No branch was created."
1643            );
1644        }
1645
1646        let path_str = path.display().to_string();
1647        self.git(&["worktree", "add", "-b", &branch, &path_str, start])
1648            .map_err(|e| {
1649                spar_err!(
1650                    "could not create a worktree for part {index} of #{parent}. {}",
1651                    e.last_line()
1652                )
1653            })?;
1654        // Recorded before anything else can fail. An unrecorded branch is one
1655        // `prune_branches` will never remove.
1656        self.record_branch(&branch, "split", parent);
1657        Ok((path, branch))
1658    }
1659
1660    /// The first part branch nothing is already sitting on.
1661    ///
1662    /// Origin as well as local, because a part's branch outlives the local one:
1663    /// a second split of the same pull request finds its own earlier branches
1664    /// deleted here but alive on origin, where the pull requests that reviewed
1665    /// them still point at them.
1666    fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
1667        for attempt in 1..=SPLIT_SLOTS {
1668            let slot = split_slot(parent, index, attempt);
1669            let branch = format!("{}{slot}", self.branch_prefix);
1670            self.git_try(&["fetch", "origin", &branch]);
1671            if !self.rev_exists(&self.root, &branch)
1672                && !self.rev_exists(&self.root, &format!("origin/{branch}"))
1673            {
1674                return Ok(slot);
1675            }
1676        }
1677        bail!(
1678            "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
1679             names are all taken. Inspect the existing branches and child pull requests. Finish \
1680             recording the earlier split, or remove every retained local worktree and branch, \
1681             child pull request, and remote split branch before starting over.",
1682            self.branch_for_split(parent, index)
1683        )
1684    }
1685
1686    /// Whether a previous attempt pushed any branch for this split.
1687    ///
1688    /// The parent comment is the normal retry marker. A branch is the fallback
1689    /// when that comment or the pull request creation failed after the push.
1690    /// Reading origin directly makes the guard survive a fresh clone.
1691    pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
1692        let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
1693        Ok(!self
1694            .git(&["ls-remote", "--heads", "origin", &pattern])?
1695            .trim()
1696            .is_empty())
1697    }
1698
1699    /// Throw one part away: its worktree, its branch, and its record.
1700    ///
1701    /// For a part that would not stand on its own. Nothing has been pushed at
1702    /// that point, so this leaves no trace anywhere but the log. Takes what
1703    /// `worktree_for_split` returned, since the name it settled on is not
1704    /// derivable from the parent and the index.
1705    pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
1706        match self.branch_deletion_is_safe(branch) {
1707            Ok(true) => {}
1708            Ok(false) => {
1709                logdim!(
1710                    "kept {branch} and {} because no surviving ref preserves its tip",
1711                    dir.display()
1712                );
1713                return;
1714            }
1715            Err(error) => {
1716                logdim!(
1717                    "kept {branch} and {} because preservation could not be verified: {}",
1718                    dir.display(),
1719                    error.last_line()
1720                );
1721                return;
1722            }
1723        }
1724        match self.remove_worktree_at(dir) {
1725            Ok(true) => match self.delete_branch_if_safe(branch) {
1726                Ok(true) => self.forget_branch(branch),
1727                Ok(false) => {
1728                    logdim!("kept {branch} because its tip or reflog changed before deletion")
1729                }
1730                Err(error) => logdim!(
1731                    "kept {branch} because deletion safety could not be rechecked: {}",
1732                    error.last_line()
1733                ),
1734            },
1735            Ok(false) => {}
1736            Err(error) => logdim!(
1737                "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
1738                dir.display(),
1739                error.last_line()
1740            ),
1741        }
1742    }
1743
1744    /// Discard one exact mechanical slice that the split workflow just made.
1745    ///
1746    /// Unlike ordinary release, this intentionally removes an unpushed commit.
1747    /// The caller supplies the exact disposable tip, and every file, worktree,
1748    /// ownership, and ref check must still match before anything is removed.
1749    pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
1750        let record = self.known_branches().get(branch).cloned();
1751        if record.is_none_or(|record| record.kind != "split") {
1752            logdim!("kept {branch} because no split branch record proves ownership");
1753            return false;
1754        }
1755        let local_ref = format!("refs/heads/{branch}");
1756        let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
1757            Ok(value) => value.trim().to_string(),
1758            Err(error) => {
1759                logdim!(
1760                    "kept {branch} because its tip could not be checked: {}",
1761                    error.last_line()
1762                );
1763                return false;
1764            }
1765        };
1766        if expected != disposable_head {
1767            logdim!("kept {branch} because it moved beyond the disposable slice");
1768            return false;
1769        }
1770        match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
1771            Ok(true) => {}
1772            Ok(false) => {
1773                logdim!(
1774                    "kept {branch} because its reflog contains work outside the disposable slice"
1775                );
1776                return false;
1777            }
1778            Err(error) => {
1779                logdim!(
1780                    "kept {branch} because its reflog could not be checked: {}",
1781                    error.last_line()
1782                );
1783                return false;
1784            }
1785        }
1786        match self.head_oid_checked(dir) {
1787            Ok(head) if head == disposable_head => {}
1788            Ok(_) => {
1789                logdim!(
1790                    "kept {branch} and {} because the worktree moved beyond the disposable slice",
1791                    dir.display()
1792                );
1793                return false;
1794            }
1795            Err(error) => {
1796                logdim!(
1797                    "kept {branch} and {} because its head could not be checked: {}",
1798                    dir.display(),
1799                    error.last_line()
1800                );
1801                return false;
1802            }
1803        }
1804        match self.remove_worktree_at_checked(dir) {
1805            Ok(true) => {}
1806            Ok(false) => return false,
1807            Err(error) => {
1808                logdim!(
1809                    "kept {branch} and {} because the disposable slice could not be verified: {}",
1810                    dir.display(),
1811                    error.last_line()
1812                );
1813                return false;
1814            }
1815        }
1816        if let Err(error) =
1817            self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
1818        {
1819            logdim!(
1820                "kept {branch} because its exact disposable tip could not be deleted: {}",
1821                error.last_line()
1822            );
1823            return false;
1824        }
1825        match self.exact_ref_exists_checked(&self.root, &local_ref) {
1826            Ok(false) => {
1827                self.forget_branch(branch);
1828                true
1829            }
1830            Ok(true) => {
1831                logdim!("kept {branch} because its ref still exists after deletion");
1832                false
1833            }
1834            Err(error) => {
1835                logdim!(
1836                    "kept the branch record for {branch} because deletion could not be verified: {}",
1837                    error.last_line()
1838                );
1839                false
1840            }
1841        }
1842    }
1843
1844    pub fn release_review_worktree(&self, number: i64) {
1845        let path = self.worktree_path(&format!("review-{number}"));
1846        match self.review_ref_deletion_is_safe(number) {
1847            Ok(true) => {}
1848            Ok(false) => {
1849                logdim!(
1850                    "kept {} because no surviving ref preserves its review history",
1851                    path.display()
1852                );
1853                return;
1854            }
1855            Err(error) => {
1856                logdim!(
1857                    "kept {} because review history could not be verified: {}",
1858                    path.display(),
1859                    error.last_line()
1860                );
1861                return;
1862            }
1863        }
1864        match self.remove_worktree_at(&path) {
1865            Ok(true) => match self.delete_review_ref_if_safe(number) {
1866                Ok(true) => {}
1867                Ok(false) => logdim!(
1868                    "kept {} because its review history changed before deletion",
1869                    review_ref(number)
1870                ),
1871                Err(error) => logdim!(
1872                    "kept {} because deletion safety could not be rechecked: {}",
1873                    review_ref(number),
1874                    error.last_line()
1875                ),
1876            },
1877            Ok(false) => {}
1878            Err(error) => logdim!(
1879                "kept {} because removal did not reach a confirmed quiet point: {}",
1880                path.display(),
1881                error.last_line()
1882            ),
1883        }
1884    }
1885
1886    /// Release a read-only review checkout only when every observed part of
1887    /// its Git state still matches the checkpoint captured before the calls.
1888    pub(crate) fn release_review_worktree_checked(
1889        &self,
1890        number: i64,
1891        checkpoint: &WorktreeCheckpoint,
1892    ) -> Result<()> {
1893        let path = self.worktree_path(&format!("review-{number}"));
1894        self.require_unchanged_worktree(
1895            &path,
1896            checkpoint,
1897            &format!("review worktree for PR #{number}"),
1898        )?;
1899        if !self.review_ref_deletion_is_safe(number)? {
1900            bail!(
1901                "the review reference for PR #{number} has reflog-only recovery history. The \
1902                 worktree and reference were kept."
1903            );
1904        }
1905        if !self.remove_worktree_at_checked(&path)? {
1906            bail!(
1907                "the verified review worktree at {} could not be removed, so its reference was \
1908                 kept",
1909                path.display()
1910            );
1911        }
1912        if !self.delete_review_ref_if_safe(number)? {
1913            bail!(
1914                "the review reference for PR #{number} changed before deletion. The reference was \
1915                 kept."
1916            );
1917        }
1918        Ok(())
1919    }
1920
1921    pub fn release_pr_worktree(&self, number: i64) -> bool {
1922        let path = self.worktree_path(&format!("pr-{number}"));
1923        let local = self.branch_for_pr(number);
1924        match self.branch_deletion_is_safe(&local) {
1925            Ok(true) => {}
1926            Ok(false) => {
1927                logdim!(
1928                    "kept {local} and {} because no surviving ref preserves its tip",
1929                    path.display()
1930                );
1931                return false;
1932            }
1933            Err(error) => {
1934                logdim!(
1935                    "kept {local} and {} because preservation could not be verified: {}",
1936                    path.display(),
1937                    error.last_line()
1938                );
1939                return false;
1940            }
1941        }
1942        match self.remove_worktree_at(&path) {
1943            Ok(true) => match self.delete_branch_if_safe(&local) {
1944                Ok(true) => {
1945                    self.forget_branch(&local);
1946                    true
1947                }
1948                Ok(false) => {
1949                    logdim!("kept {local} because its tip or reflog changed before deletion");
1950                    false
1951                }
1952                Err(error) => {
1953                    logdim!(
1954                        "kept {local} because deletion safety could not be rechecked: {}",
1955                        error.last_line()
1956                    );
1957                    false
1958                }
1959            },
1960            Ok(false) => false,
1961            Err(error) => {
1962                logdim!(
1963                    "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
1964                    path.display(),
1965                    error.last_line()
1966                );
1967                false
1968            }
1969        }
1970    }
1971
1972    // -- branch state -----------------------------------------------------
1973
1974    /// What to diff against: the remote tracking branch when it resolves, the
1975    /// local branch when it does not.
1976    ///
1977    /// This is not a nicety. Every "did the agent do anything" check hangs off
1978    /// this ref, and `git log` against a ref that does not exist fails silently
1979    /// and reads as "no commits". A checkout whose `origin/main` was never
1980    /// fetched would report every implementation as abandoned and throw the
1981    /// work away.
1982    pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
1983        let remote = format!("origin/{base}");
1984        if self.rev_exists(cwd, &remote) {
1985            return remote;
1986        }
1987        if self.rev_exists(cwd, base) {
1988            logdim!("origin/{base} does not resolve, comparing against local {base}");
1989            return base.to_string();
1990        }
1991        logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
1992        remote
1993    }
1994
1995    fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
1996        let spec = format!("{refname}^{{commit}}");
1997        !self
1998            .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
1999            .trim()
2000            .is_empty()
2001    }
2002
2003    pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
2004        let range = format!("{}..HEAD", self.base_ref(cwd, base));
2005        !self
2006            .git_try_at(Some(cwd), &["log", &range, "--oneline"])
2007            .trim()
2008            .is_empty()
2009    }
2010
2011    fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
2012        let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
2013        Ok(found.lines().any(|line| line.trim() == refname))
2014    }
2015
2016    fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
2017        let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
2018        count.trim().parse::<usize>().map_err(|e| {
2019            spar_err!(
2020                "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
2021                count.trim()
2022            )
2023        })
2024    }
2025
2026    pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
2027        let remote = format!("refs/remotes/origin/{base}");
2028        if self.exact_ref_exists_checked(cwd, &remote)? {
2029            return Ok(remote);
2030        }
2031        let local = format!("refs/heads/{base}");
2032        if self.exact_ref_exists_checked(cwd, &local)? {
2033            return Ok(local);
2034        }
2035        bail!("neither origin/{base} nor local branch {base} resolves")
2036    }
2037
2038    pub(crate) fn commit_count_checked(
2039        &self,
2040        cwd: &Path,
2041        refname: &str,
2042        base: &str,
2043    ) -> Result<usize> {
2044        let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
2045        let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
2046        count.trim().parse::<usize>().map_err(|e| {
2047            spar_err!(
2048                "git returned an invalid commit count for {range}: {:?} ({e})",
2049                count.trim()
2050            )
2051        })
2052    }
2053
2054    pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
2055        Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
2056    }
2057
2058    pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
2059        let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
2060        let head = head.trim().to_string();
2061        if head.is_empty() {
2062            bail!("git returned an empty HEAD for {}", cwd.display());
2063        }
2064        Ok(head)
2065    }
2066
2067    /// Whether a recorded SPAR branch's exact tip is retained by a pull request.
2068    ///
2069    /// Pull request head refs remain available after close or merge, so this is
2070    /// stronger than requiring an open pull request or a live remote branch.
2071    pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
2072        let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
2073        self.local_branch_is_preserved(branch.trim())
2074    }
2075
2076    /// Whether a recorded local branch's exact tip is retained by its pull
2077    /// request head, regardless of which branch is currently checked out.
2078    pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
2079        let known = self.known_branches();
2080        let Some(record) = known.get(branch) else {
2081            return Ok(false);
2082        };
2083        self.branch_is_preserved_checked(branch, record)
2084    }
2085
2086    /// Whether tracked, staged, or non-ignored untracked files are uncommitted.
2087    pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
2088        has_uncommitted_work(cwd)
2089    }
2090
2091    /// Whether removing a worktree would delete any local file Git does not
2092    /// reproduce from its commits, including ignored untracked files.
2093    fn has_recoverable_work(&self, cwd: &Path) -> Result<bool> {
2094        repository_has_recoverable_work(cwd, true)
2095    }
2096
2097    /// Record ignored artifacts that existed before an editing call.
2098    pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
2099        let attributes = attribute_state(cwd)?;
2100        Ok(WorktreeBaseline {
2101            attributes,
2102            ignored_untracked: ignored_untracked_state(cwd)?,
2103            git_state: safe_git_state(cwd)?,
2104        })
2105    }
2106
2107    /// Capture the Git state of a checkout intended to remain read only while
2108    /// external commands inspect it.
2109    pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
2110        let attributes = attribute_state(cwd)?;
2111        Ok(WorktreeCheckpoint {
2112            path: std::fs::canonicalize(cwd)
2113                .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
2114            attributes,
2115            git_state: safe_git_state(cwd)?,
2116            ignored_untracked: ignored_untracked_state(cwd)?,
2117        })
2118    }
2119
2120    /// Require a read-only checkout to match a previously captured checkpoint.
2121    /// Any probe failure is an error because deletion cannot be proven safe.
2122    pub(crate) fn require_unchanged_worktree(
2123        &self,
2124        cwd: &Path,
2125        checkpoint: &WorktreeCheckpoint,
2126        label: &str,
2127    ) -> Result<()> {
2128        let resolved = std::fs::canonicalize(cwd).map_err(|e| {
2129            crate::error::SparError::uncertain_write(format!(
2130                "could not resolve the {label} at {} after inspection: {e}. It was kept.",
2131                cwd.display()
2132            ))
2133        })?;
2134        if resolved != checkpoint.path {
2135            return Err(uncertain_worktree_change(
2136                cwd,
2137                format!(
2138                    "the {label} moved from {} to {} during inspection. It was kept.",
2139                    checkpoint.path.display(),
2140                    resolved.display()
2141                ),
2142            ));
2143        }
2144        let attributes = attribute_state(cwd).map_err(|e| {
2145            uncertain_worktree_change(
2146                cwd,
2147                format!(
2148                    "could not verify attribute files in the {label} at {}: {}. It was kept.",
2149                    cwd.display(),
2150                    e.last_line()
2151                ),
2152            )
2153        })?;
2154        if attributes != checkpoint.attributes {
2155            return Err(uncertain_worktree_change(
2156                cwd,
2157                format!(
2158                    "attribute files in the {label} at {} changed during inspection. It was \
2159                     kept for recovery.",
2160                    cwd.display()
2161                ),
2162            ));
2163        }
2164        let git_state = git_state(cwd).map_err(|e| {
2165            uncertain_worktree_change(
2166                cwd,
2167                format!(
2168                    "could not verify the Git state of the {label} at {}: {}. It was kept.",
2169                    cwd.display(),
2170                    e.last_line()
2171                ),
2172            )
2173        })?;
2174        let ignored = ignored_untracked_state(cwd).map_err(|e| {
2175            uncertain_worktree_change(
2176                cwd,
2177                format!(
2178                    "could not verify untracked files in the {label} at {}: {}. It was kept.",
2179                    cwd.display(),
2180                    e.last_line()
2181                ),
2182            )
2183        })?;
2184        if git_state != checkpoint.git_state
2185            || checkpoint
2186                .ignored_untracked
2187                .changed_beyond_generated(&ignored)
2188        {
2189            return Err(uncertain_worktree_change(
2190                cwd,
2191                format!(
2192                    "the {label} at {} changed during a read-only inspection. It was kept for \
2193                     recovery.",
2194                    cwd.display()
2195                ),
2196            ));
2197        }
2198        Ok(())
2199    }
2200
2201    /// Refuse to discard ignored files that appeared during a call.
2202    ///
2203    /// Call this when the call reported success but produced no commit-worthy
2204    /// status. Existing ignored files are harmless because they are present in
2205    /// `baseline`; only new paths stop cleanup, and recognized build and cache
2206    /// output is not one of them. Running the project's tests is what a call is
2207    /// asked to do, and whatever wrote that output writes it again.
2208    pub(crate) fn refuse_new_ignored_files(
2209        &self,
2210        cwd: &Path,
2211        baseline: &WorktreeBaseline,
2212    ) -> Result<()> {
2213        self.check_new_ignored_files(cwd, baseline).map(drop)
2214    }
2215
2216    /// The generated paths the check let through, for one report per attempt
2217    /// rather than one per check.
2218    fn allow_generated_ignored_files(
2219        &self,
2220        cwd: &Path,
2221        baseline: &WorktreeBaseline,
2222    ) -> Result<Vec<PathBuf>> {
2223        self.check_new_ignored_files(cwd, baseline)
2224    }
2225
2226    fn check_new_ignored_files(
2227        &self,
2228        cwd: &Path,
2229        baseline: &WorktreeBaseline,
2230    ) -> Result<Vec<PathBuf>> {
2231        self.refuse_changed_attributes(cwd, baseline)?;
2232        let after = ignored_untracked_state(cwd).map_err(|e| {
2233            uncertain_worktree_change(
2234                cwd,
2235                format!(
2236                    "could not verify untracked files in {} after editing: {}. The worktree was \
2237                     kept for recovery.",
2238                    cwd.display(),
2239                    e.last_line()
2240                ),
2241            )
2242        })?;
2243        let changed = baseline.ignored_untracked.changed_paths(&after);
2244        if changed.is_empty() {
2245            return Ok(Vec::new());
2246        }
2247        let (generated, changed): (Vec<_>, Vec<_>) = changed
2248            .into_iter()
2249            .partition(|path| after.is_ignored(path) && is_generated_artifact(path));
2250        if changed.is_empty() {
2251            return Ok(generated);
2252        }
2253        let mut listed = changed
2254            .iter()
2255            .take(5)
2256            .map(|path| format!("{:?}", path.as_os_str()))
2257            .collect::<Vec<_>>()
2258            .join(", ");
2259        if changed.len() > 5 {
2260            listed.push_str(&format!(", and {} more", changed.len() - 5));
2261        }
2262        Err(uncertain_worktree_change(
2263            cwd,
2264            format!(
2265                "the editing call created or changed untracked or ignored file(s) in {} that \
2266                 cannot be represented by a managed commit: {listed}. The worktree was kept for \
2267                 recovery.",
2268                cwd.display()
2269            ),
2270        ))
2271    }
2272
2273    /// Existing untracked files belong to the checkout owner, even when a call
2274    /// also produces a valid tracked change. Refuse their modification or
2275    /// deletion before accepting the tracked result. Rebuilt output is not that:
2276    /// it is ignored on both sides and under a known build or cache directory,
2277    /// which is where the command the call was asked to run puts it.
2278    pub(crate) fn refuse_changed_existing_untracked(
2279        &self,
2280        cwd: &Path,
2281        baseline: &WorktreeBaseline,
2282    ) -> Result<()> {
2283        self.check_changed_existing_untracked(cwd, baseline)
2284            .map(drop)
2285    }
2286
2287    fn allow_changed_generated_artifacts(
2288        &self,
2289        cwd: &Path,
2290        baseline: &WorktreeBaseline,
2291    ) -> Result<Vec<PathBuf>> {
2292        self.check_changed_existing_untracked(cwd, baseline)
2293    }
2294
2295    fn check_changed_existing_untracked(
2296        &self,
2297        cwd: &Path,
2298        baseline: &WorktreeBaseline,
2299    ) -> Result<Vec<PathBuf>> {
2300        self.refuse_changed_attributes(cwd, baseline)?;
2301        let after = ignored_untracked_state(cwd).map_err(|e| {
2302            uncertain_worktree_change(
2303                cwd,
2304                format!(
2305                    "could not verify existing untracked files in {} after editing: {}. The \
2306                     worktree was kept for recovery.",
2307                    cwd.display(),
2308                    e.last_line()
2309                ),
2310            )
2311        })?;
2312        let changed = baseline.ignored_untracked.changed_existing_paths(&after);
2313        if changed.is_empty() {
2314            return Ok(Vec::new());
2315        }
2316        let (generated, changed): (Vec<_>, Vec<_>) = changed.into_iter().partition(|path| {
2317            baseline.ignored_untracked.is_ignored(path)
2318                && after.is_ignored(path)
2319                && is_generated_artifact(path)
2320        });
2321        if changed.is_empty() {
2322            return Ok(generated);
2323        }
2324        let mut listed = changed
2325            .iter()
2326            .take(5)
2327            .map(|path| format!("{:?}", path.as_os_str()))
2328            .collect::<Vec<_>>()
2329            .join(", ");
2330        if changed.len() > 5 {
2331            listed.push_str(&format!(", and {} more", changed.len() - 5));
2332        }
2333        Err(uncertain_worktree_change(
2334            cwd,
2335            format!(
2336                "the editing call changed or deleted existing untracked file(s) in {}: \
2337                 {listed}. The worktree was kept for recovery.",
2338                cwd.display()
2339            ),
2340        ))
2341    }
2342
2343    /// Refuse a byte or mode change that the index did not represent.
2344    ///
2345    /// Clean filters can normalize a working file back to its existing blob,
2346    /// and index flags can hide a change from porcelain status. Comparing the
2347    /// actual tracked files on both sides keeps those bytes from being treated
2348    /// as disposable just because Git has no diff for them.
2349    pub(crate) fn refuse_unrepresented_tracked_changes(
2350        &self,
2351        cwd: &Path,
2352        baseline: &WorktreeBaseline,
2353    ) -> Result<()> {
2354        self.refuse_changed_attributes(cwd, baseline)?;
2355        let after = safe_git_state(cwd).map_err(|e| {
2356            uncertain_worktree_change(
2357                cwd,
2358                format!(
2359                    "could not verify tracked files in {} after editing: {}. The worktree was \
2360                     kept for recovery.",
2361                    cwd.display(),
2362                    e.last_line()
2363                ),
2364            )
2365        })?;
2366        let mut changed = Vec::new();
2367        let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
2368            uncertain_worktree_change(
2369                cwd,
2370                format!(
2371                    "could not record untracked files before verifying transformed content in {}: \
2372                     {}. The worktree was kept for recovery.",
2373                    cwd.display(),
2374                    e.last_line()
2375                ),
2376            )
2377        })?;
2378        let mut filter_was_run = false;
2379        let mut filter_problem = None;
2380        let mut repositories: BTreeSet<PathBuf> =
2381            baseline.git_state.repositories.keys().cloned().collect();
2382        repositories.extend(after.repositories.keys().cloned());
2383        'repositories: for repository_path in repositories {
2384            let before_repository = baseline.git_state.repositories.get(&repository_path);
2385            let after_repository = after.repositories.get(&repository_path);
2386            if before_repository.is_none() || after_repository.is_none() {
2387                changed.push(repository_path.clone());
2388                continue;
2389            }
2390            if before_repository.map(|repository| &repository.gitlinks)
2391                != after_repository.map(|repository| &repository.gitlinks)
2392            {
2393                changed.push(repository_path.join("<gitlinks>"));
2394            }
2395            let mut paths = BTreeSet::new();
2396            if let Some(repository) = before_repository {
2397                paths.extend(repository.tracked.keys().cloned());
2398            }
2399            if let Some(repository) = after_repository {
2400                paths.extend(repository.tracked.keys().cloned());
2401            }
2402            for path in paths {
2403                let before = before_repository.and_then(|repository| repository.tracked.get(&path));
2404                let current = after_repository.and_then(|repository| repository.tracked.get(&path));
2405                let worktree_changed =
2406                    before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
2407                let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
2408                    != current.map(|entry| (&entry.index_mode, &entry.index_oid));
2409                if !worktree_changed {
2410                    continue;
2411                }
2412                if !index_changed {
2413                    changed.push(repository_path.join(&path));
2414                    continue;
2415                }
2416                let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
2417                let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
2418                let Some(current_entry) = current else {
2419                    continue;
2420                };
2421                let Some(current_worktree) = current_worktree else {
2422                    continue;
2423                };
2424                let content_changed =
2425                    before_worktree.map(|file| file.content) != Some(current_worktree.content);
2426                let mode_changed = before_worktree.map(|file| file.mode.as_str())
2427                    != Some(current_worktree.mode.as_str());
2428                let repository = cwd.join(&repository_path);
2429                let represented_content = if content_changed {
2430                    filter_was_run = true;
2431                    let result =
2432                        filtered_index_content(&repository, &path, &current_entry.index_oid);
2433                    self.refuse_changed_attributes(cwd, baseline)?;
2434                    match result {
2435                        Ok(expected) => expected == current_worktree.content,
2436                        Err(error) => {
2437                            filter_problem = Some(format!(
2438                                "could not verify transformed content for {:?}: {}",
2439                                repository_path.join(&path),
2440                                error.last_line()
2441                            ));
2442                            false
2443                        }
2444                    }
2445                } else {
2446                    true
2447                };
2448                let represented_mode =
2449                    !mode_changed || current_worktree.mode == current_entry.index_mode;
2450                if !represented_content || !represented_mode {
2451                    changed.push(repository_path.join(&path));
2452                }
2453                if filter_problem.is_some() {
2454                    break 'repositories;
2455                }
2456            }
2457        }
2458        if filter_was_run {
2459            self.refuse_changed_attributes(cwd, baseline)?;
2460            let verified = safe_git_state(cwd).map_err(|e| {
2461                uncertain_worktree_change(
2462                    cwd,
2463                    format!(
2464                        "could not recheck tracked files after verifying transformed content in \
2465                         {}: {}. The worktree was kept for recovery.",
2466                        cwd.display(),
2467                        e.last_line()
2468                    ),
2469                )
2470            })?;
2471            let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
2472                uncertain_worktree_change(
2473                    cwd,
2474                    format!(
2475                        "could not recheck untracked files after verifying transformed content \
2476                         in {}: {}. The worktree was kept for recovery.",
2477                        cwd.display(),
2478                        e.last_line()
2479                    ),
2480                )
2481            })?;
2482            if verified != after
2483                || before_filter_untracked.changed_beyond_generated(&verified_untracked)
2484            {
2485                return Err(uncertain_worktree_change(
2486                    cwd,
2487                    "a content filter changed the worktree while SPAR verified the managed \
2488                     commit. The worktree was kept for recovery.",
2489                ));
2490            }
2491            self.refuse_changed_existing_untracked(cwd, baseline)?;
2492        }
2493        if let Some(problem) = filter_problem {
2494            return Err(uncertain_worktree_change(
2495                cwd,
2496                format!("{problem}. The worktree was kept for recovery."),
2497            ));
2498        }
2499        if changed.is_empty() {
2500            return Ok(());
2501        }
2502        let mut listed = changed
2503            .iter()
2504            .take(5)
2505            .map(|path| format!("{:?}", path.as_os_str()))
2506            .collect::<Vec<_>>()
2507            .join(", ");
2508        if changed.len() > 5 {
2509            listed.push_str(&format!(", and {} more", changed.len() - 5));
2510        }
2511        Err(uncertain_worktree_change(
2512            cwd,
2513            format!(
2514                "the editing call changed tracked working-file bytes, modes, repositories, or \
2515                 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
2516                 recovery."
2517            ),
2518        ))
2519    }
2520
2521    pub(crate) fn refuse_changed_attributes(
2522        &self,
2523        cwd: &Path,
2524        baseline: &WorktreeBaseline,
2525    ) -> Result<()> {
2526        let after = attribute_state(cwd).map_err(|e| {
2527            uncertain_worktree_change(
2528                cwd,
2529                format!(
2530                    "could not verify attribute files in {} after editing: {}. The worktree was \
2531                     kept for recovery.",
2532                    cwd.display(),
2533                    e.last_line()
2534                ),
2535            )
2536        })?;
2537        if after == baseline.attributes {
2538            return Ok(());
2539        }
2540        Err(uncertain_worktree_change(
2541            cwd,
2542            format!(
2543                "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
2544                 refused to run a Git operation that could select a new external filter.",
2545                cwd.display()
2546            ),
2547        ))
2548    }
2549
2550    /// Commit a successful editing call from the trusted harness process.
2551    ///
2552    /// Editing sandboxes only need the working tree. They never need writable
2553    /// access to the repository's object database, refs, config, or hooks.
2554    pub(crate) fn commit_pending_changes(
2555        &self,
2556        cwd: &Path,
2557        baseline: &WorktreeBaseline,
2558        preferred_subject: &str,
2559        fallback_subject: &str,
2560    ) -> Result<bool> {
2561        let mut artifacts = GeneratedArtifacts::default();
2562        self.refuse_changed_attributes(cwd, baseline)?;
2563        artifacts.changed(self.allow_changed_generated_artifacts(cwd, baseline)?);
2564        refuse_unsafe_index_flags(cwd)?;
2565        if !self.has_uncommitted_changes(cwd)? {
2566            artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2567            artifacts.report(cwd);
2568            return Ok(false);
2569        }
2570        self.stage_managed_changes(cwd, baseline).map_err(|e| {
2571            e.with_message(format!(
2572                "could not stage changes in {}: {}",
2573                cwd.display(),
2574                e.last_line()
2575            ))
2576        })?;
2577        // Ignored paths remain untracked after staging. Only known generated
2578        // output may remain beside an otherwise complete managed commit.
2579        artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2580        let changed_gitlinks = changed_staged_gitlinks(cwd)?;
2581        if !changed_gitlinks.is_empty() {
2582            let listed = changed_gitlinks
2583                .iter()
2584                .take(5)
2585                .map(|path| format!("{:?}", path.as_os_str()))
2586                .collect::<Vec<_>>()
2587                .join(", ");
2588            bail!(
2589                "the editing call added or changed a gitlink at {listed}. It was staged but not \
2590                 committed because the referenced repository objects might exist only inside \
2591                 this worktree. The worktree was kept for recovery."
2592            );
2593        }
2594        let mut subject = self.clean_title(preferred_subject)?;
2595        if subject.trim().is_empty() {
2596            subject = self.clean_title(fallback_subject)?;
2597        }
2598        self.commit_staged_changes(cwd, &subject).map_err(|e| {
2599            e.with_message(format!(
2600                "could not commit changes in {}: {}. The staged files were kept.",
2601                cwd.display(),
2602                e.last_line()
2603            ))
2604        })?;
2605        if has_tracked_or_staged_work(cwd)? {
2606            bail!(
2607                "the commit in {} left additional uncommitted files. They were kept for \
2608                 recovery.",
2609                cwd.display()
2610            );
2611        }
2612        artifacts.changed(self.allow_changed_generated_artifacts(cwd, baseline)?);
2613        artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2614        artifacts.report(cwd);
2615        Ok(true)
2616    }
2617
2618    fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
2619        let after = ignored_untracked_state(cwd)?;
2620        self.git_at_without_automation(cwd, &["add", "-u"])?;
2621        let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
2622        if paths.is_empty() {
2623            return Ok(());
2624        }
2625        let mut input = Vec::new();
2626        for path in paths {
2627            input.extend(os_str_bytes(path.as_os_str())?);
2628            input.push(0);
2629        }
2630        let argv = git_without_automation_argv(&[
2631            "--literal-pathspecs",
2632            "add",
2633            "--pathspec-from-file=-",
2634            "--pathspec-file-nul",
2635        ]);
2636        proc::run_with_input_bytes(
2637            &argv,
2638            &self.git_opts(Some(cwd), true).stop_descendants(true),
2639            &input,
2640        )?;
2641        Ok(())
2642    }
2643
2644    /// Commit an index prepared by the parent without signing, hooks, or
2645    /// inherited repository automation.
2646    pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
2647        self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
2648            .map(|_| ())
2649    }
2650
2651    /// How many commits `refname` carries that the base does not.
2652    ///
2653    /// Counted from the commits themselves rather than from `commit_subjects`,
2654    /// which drops a commit whose message is empty. The guards in
2655    /// `worktree_add` decide whether to delete a branch on this number, and an
2656    /// empty message must not read as an empty branch.
2657    pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
2658        let range = format!("{}..{refname}", self.base_ref(cwd, base));
2659        self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
2660            .trim()
2661            .parse()
2662            .unwrap_or(0)
2663    }
2664
2665    /// One `hash subject` line per commit `refname` carries that the base does
2666    /// not, oldest first. For showing a person what is on a branch, so the
2667    /// hash keeps a commit with no message from listing as nothing.
2668    pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
2669        let range = format!("{}..{refname}", self.base_ref(cwd, base));
2670        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
2671            .lines()
2672            .map(str::to_string)
2673            .collect()
2674    }
2675
2676    /// The commits `later` carries that `earlier` does not, oldest first, when
2677    /// `earlier` is genuinely behind it.
2678    ///
2679    /// `None` when it is not an ancestor, which is not the same as nothing
2680    /// having landed. `rewrite_commits_if_needed` rewrites hashes from the first
2681    /// offending commit spar made onward, so a head recorded part way through a
2682    /// round can still be a readable object and no longer be on the branch. `git log` answers that
2683    /// with every commit on the branch, so without the check the one caller
2684    /// would report the whole branch as unread, which is the widest possible
2685    /// wrong answer.
2686    ///
2687    /// No `base_ref` resolution, unlike its neighbours: these are commits rather
2688    /// than branch names, and putting a sha through it logs a fallback line
2689    /// every time.
2690    pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
2691        let ancestor = self
2692            .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
2693            .is_ok();
2694        if !ancestor {
2695            return None;
2696        }
2697        let range = format!("{earlier}..{later}");
2698        Some(
2699            self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
2700                .lines()
2701                .map(str::to_string)
2702                .collect(),
2703        )
2704    }
2705
2706    /// The subjects of the commits `refname` carries that the base does not,
2707    /// oldest first.
2708    pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
2709        let range = format!("{}..{refname}", self.base_ref(cwd, base));
2710        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
2711            .lines()
2712            .map(str::trim)
2713            .filter(|line| !line.is_empty())
2714            .map(str::to_string)
2715            .collect()
2716    }
2717
2718    /// The paths this checkout changes relative to the base, sorted.
2719    ///
2720    /// A three dot range, matching `diff_stat`: what the branch did, not what
2721    /// the base has done since.
2722    ///
2723    /// `--no-renames` because a rename reported as its destination alone leaves
2724    /// the source out of the list, and a part carrying only the destination
2725    /// would be a copy. As a deletion and an addition it is two paths, which a
2726    /// part can carry together or leave to the leftover report.
2727    ///
2728    /// `-z` because without it git writes paths for display: anything
2729    /// non-ASCII comes back escaped and wrapped in quotes, and that string is
2730    /// not a path. A part built from one carries a pathspec matching no file,
2731    /// so the file never reaches the slice while every list still says the part
2732    /// took it. It also keeps a path with a space at either end intact.
2733    pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
2734        let range = format!("{}...HEAD", self.base_ref(cwd, base));
2735        self.git_try_at(
2736            Some(cwd),
2737            &["diff", "--name-only", "--no-renames", "-z", &range],
2738        )
2739        .split('\0')
2740        .filter(|path| !path.is_empty())
2741        .map(str::to_string)
2742        .collect()
2743    }
2744
2745    /// The files one commit range touched, for checking a claim against the
2746    /// diff rather than against the report that made it.
2747    pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
2748        self.git_try_at(
2749            Some(cwd),
2750            &[
2751                "diff",
2752                "--name-only",
2753                "--no-renames",
2754                "-z",
2755                &format!("{from}..{to}"),
2756            ],
2757        )
2758        .split('\0')
2759        .filter(|path| !path.is_empty())
2760        .map(str::to_string)
2761        .collect()
2762    }
2763
2764    /// Where `refname` left the base: the commit its own change is measured
2765    /// from, and the one a slice of that change has to be taken against.
2766    pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
2767        let base_ref = self.base_ref(cwd, base);
2768        let out = self
2769            .git_at(Some(cwd), &["merge-base", &base_ref, refname])
2770            .map_err(|e| {
2771                spar_err!(
2772                    "could not find where {refname} and {base_ref} diverged. {}",
2773                    e.last_line()
2774                )
2775            })?;
2776        let sha = out.trim().to_string();
2777        if sha.is_empty() {
2778            bail!("{refname} and {base_ref} share no history");
2779        }
2780        Ok(sha)
2781    }
2782
2783    pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
2784        let range = format!("{}...HEAD", self.base_ref(cwd, base));
2785        let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
2786        full.trim().to_string()
2787    }
2788
2789    /// Scrub commit messages that slipped past the prompt.
2790    ///
2791    /// `git filter-branch` calls back into this same binary, so there is no
2792    /// interpreter to find and no second copy of the rules to drift.
2793    ///
2794    /// `floor` is the head spar found when it started, and nothing at or below
2795    /// it is rewritten. The style gate is for what spar writes, and a person's
2796    /// commit message is not that: rewriting one changes a published SHA, which
2797    /// diverges the branch under somebody's own checkout and is exactly the
2798    /// history rewrite this design promises never to do. A `None` floor means
2799    /// every commit above the base is spar's own, which is true of a branch
2800    /// spar created in this invocation and of nothing else.
2801    pub fn rewrite_commits_if_needed(
2802        &self,
2803        cwd: &Path,
2804        base: &str,
2805        floor: Option<&str>,
2806    ) -> Result<()> {
2807        let base_ref = self.base_ref(cwd, base);
2808        let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
2809        self.report_commits_left_alone(cwd, &base_ref, floor);
2810
2811        let range = format!("{floor}..HEAD");
2812        let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
2813
2814        let offenders = raw
2815            .split('\x1e')
2816            .filter_map(|entry| entry.split_once('\0'))
2817            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
2818            .count();
2819        if offenders == 0 {
2820            return Ok(());
2821        }
2822        logdim!("{offenders} commit message(s) violated style rules, rewriting");
2823
2824        let exe = self_binary()?;
2825        let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
2826
2827        let argv: Vec<String> = [
2828            "git",
2829            "filter-branch",
2830            "-f",
2831            "--msg-filter",
2832            &filter,
2833            &range,
2834        ]
2835        .iter()
2836        .map(|s| s.to_string())
2837        .collect();
2838        let opts = ExecOpts::new()
2839            .cwd(cwd)
2840            .check(false)
2841            .timeout_secs(600)
2842            .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
2843            .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
2844            .env(
2845                "SPAR_BAN_AI_ATTRIBUTION",
2846                bool_env(self.style.ban_ai_attribution),
2847            );
2848        let _ = proc::run(&argv, &opts);
2849
2850        let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
2851        if !style::violations(&after, &self.style).is_empty() {
2852            bail!(
2853                "commit messages still violate style rules after a rewrite in {}.",
2854                cwd.display()
2855            );
2856        }
2857        Ok(())
2858    }
2859
2860    /// Say out loud when a message spar will not touch breaks the style rules.
2861    ///
2862    /// Silence here would read as "there was nothing to scrub", and the point
2863    /// of the floor is that there was something and spar chose to leave it.
2864    fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
2865        if floor == base_ref {
2866            return;
2867        }
2868        let raw = self.git_try_at(
2869            Some(cwd),
2870            &[
2871                "log",
2872                &format!("{base_ref}..{floor}"),
2873                "--format=%H%x00%B%x1e",
2874            ],
2875        );
2876        let left: Vec<&str> = raw
2877            .split('\x1e')
2878            .filter_map(|entry| entry.split_once('\0'))
2879            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
2880            .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
2881            .collect();
2882        if left.is_empty() {
2883            return;
2884        }
2885        logdim!(
2886            "{} commit message(s) already on the branch break the style rules and were left \
2887             alone: {}. spar does not rewrite commits it did not write.",
2888            left.len(),
2889            left.join(", ")
2890        );
2891    }
2892
2893    /// Push by explicit refspec from HEAD, leasing the head spar reviewed.
2894    ///
2895    /// A resumed PR is checked out under a local name (`pr-N`) that does not
2896    /// match its remote branch, so pushing by branch name would resolve the
2897    /// wrong local ref or fail outright.
2898    ///
2899    /// `expected` is the remote head spar last read. A bare `--force-with-lease`
2900    /// leases the implicit tracking ref instead, which is shared across every
2901    /// worktree and is updated by any `git fetch`. The editing agent is a real
2902    /// CLI with shell access, and fetching is a normal thing for it to do: one
2903    /// `git fetch` mid round moves the tracking ref to whatever the remote holds
2904    /// and the lease then passes against a head spar never reviewed, which
2905    /// silently drops somebody's commit. Naming the head makes the push refuse
2906    /// exactly that case.
2907    pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
2908        let expected = expected.map(str::trim).filter(|head| !head.is_empty());
2909        if let Some(head) = expected {
2910            self.refuse_push_that_drops(cwd, head)?;
2911        }
2912        let refspec = format!("HEAD:{branch}");
2913        let lease = match expected {
2914            Some(head) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
2915            None => "--force-with-lease".to_string(),
2916        };
2917        let pushed = self
2918            .git_at(Some(cwd), &["push", &lease, "origin", &refspec])
2919            .map(|_| ())
2920            .map_err(|e| {
2921                spar_err!(
2922                    "could not push to origin/{branch}. {}\nCheck push access and whether the \
2923                     branch moved under you.",
2924                    e.last_line()
2925                )
2926            });
2927        self.record_write(pushed)
2928    }
2929
2930    /// Refuse a push whose HEAD does not contain the head spar reviewed.
2931    ///
2932    /// The lease answers the remote moving. This answers the local side moving:
2933    /// an agent that reset or rebased the worktree leaves a HEAD that would
2934    /// publish over the reviewed head rather than on top of it, and the lease
2935    /// cannot tell the difference because the remote is exactly where spar left
2936    /// it.
2937    fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
2938        let head = self.head_oid_checked(cwd)?;
2939        if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
2940            return Ok(());
2941        }
2942        bail!(
2943            "refusing to push {head} because it does not contain the reviewed head {expected}. \
2944             The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
2945             the intended commits on top of it before resuming.",
2946            cwd.display()
2947        )
2948    }
2949
2950    /// Create one remote branch for a split without ever moving an existing ref.
2951    ///
2952    /// `worktree_for_split` chooses a name that is free locally and on origin,
2953    /// but another writer can still take it before the push. An empty expected
2954    /// value in the lease makes this an atomic create: it creates an absent ref,
2955    /// accepts an identical ref as a no-op, and never moves an existing ref.
2956    /// The shared `push` method cannot be used because its lease permits
2957    /// updating a ref fetched earlier.
2958    pub fn push_split_branch(
2959        &self,
2960        cwd: &Path,
2961        branch: &str,
2962    ) -> std::result::Result<(), SplitPushError> {
2963        let remote_ref = format!("refs/heads/{branch}");
2964        let lease = format!("--force-with-lease={remote_ref}:");
2965        let refspec = format!("HEAD:{remote_ref}");
2966        let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
2967            Ok(_) => Ok(()),
2968            Err(push_error) => {
2969                let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
2970                let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
2971                reconcile_failed_split_push(branch, push_error, local, remote)
2972            }
2973        };
2974        self.record_write(result)
2975    }
2976
2977    // -- gh ---------------------------------------------------------------
2978
2979    pub fn gh(&self, args: &[&str]) -> Result<String> {
2980        self.gh_at(None, args)
2981    }
2982
2983    pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
2984        let mut argv = vec!["gh".to_string()];
2985        argv.extend(args.iter().map(|s| s.to_string()));
2986        proc::run(
2987            &argv,
2988            &ExecOpts::new()
2989                .cwd(cwd.unwrap_or(&self.root))
2990                .timeout_secs(300),
2991        )
2992    }
2993
2994    /// Run gh with something on its stdin.
2995    ///
2996    /// A tracker body is far too long to pass on argv, and `--body-file -` is
2997    /// how gh takes one. `proc::exec` already wires the pipe, so this is a
2998    /// sibling of `gh_at` rather than anything new.
2999    pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
3000        let mut argv = vec!["gh".to_string()];
3001        argv.extend(args.iter().map(|s| s.to_string()));
3002        proc::run(
3003            &argv,
3004            &ExecOpts::new()
3005                .cwd(&self.root)
3006                .timeout_secs(300)
3007                .stdin(stdin),
3008        )
3009    }
3010
3011    pub fn gh_try(&self, args: &[&str]) -> String {
3012        let mut argv = vec!["gh".to_string()];
3013        argv.extend(args.iter().map(|s| s.to_string()));
3014        proc::run(
3015            &argv,
3016            &ExecOpts::new()
3017                .cwd(&self.root)
3018                .check(false)
3019                .timeout_secs(300),
3020        )
3021        .unwrap_or_default()
3022    }
3023
3024    /// The login `gh` is authenticated as.
3025    ///
3026    /// A hard error, never a degradation. Everything spar wrote has to be
3027    /// excluded from what it answers, and custody cannot be read from git
3028    /// authorship, so this is the only thing that tells spar's own comments
3029    /// from somebody else's. Without it the failure is not "answers a bit too
3030    /// much", it is a thread where spar answers itself until somebody notices.
3031    ///
3032    /// Not cached on disk: `gh auth switch` between runs would make a stored
3033    /// answer wrong in exactly the way that produces that thread.
3034    pub fn viewer_login(&self) -> Result<&str> {
3035        if let Some(login) = self.viewer.get() {
3036            return Ok(login);
3037        }
3038        let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
3039        let login = if !rest.trim().is_empty() {
3040            rest.trim().to_string()
3041        } else {
3042            // A token that cannot read /user can still answer for itself in
3043            // GraphQL, which is the case on some Enterprise installs.
3044            self.gh(&[
3045                "api",
3046                "graphql",
3047                "-f",
3048                "query={ viewer { login } }",
3049                "--jq",
3050                ".data.viewer.login",
3051            ])
3052            .map_err(|e| {
3053                spar_err!(
3054                    "could not find out who `gh` is authenticated as, so spar cannot tell its \
3055                     own comments from anybody else's. {}\nRun `gh auth status`.",
3056                    e.last_line()
3057                )
3058            })?
3059            .trim()
3060            .to_string()
3061        };
3062        if login.is_empty() {
3063            bail!("`gh` reported an empty login. Run `gh auth status`.");
3064        }
3065        Ok(self.viewer.get_or_init(|| login))
3066    }
3067
3068    /// One issue as it stands, open or closed.
3069    ///
3070    /// `fetch_issues` reads a queue to work: it drops a closed issue and fails
3071    /// when nothing survives. Both are wrong for reading one issue back, where
3072    /// closed is an answer and the empty case cannot arise.
3073    pub fn read_issue(&self, number: i64) -> Result<Issue> {
3074        let text = self
3075            .gh(&[
3076                "issue",
3077                "view",
3078                &number.to_string(),
3079                "--json",
3080                "number,title,body,labels,state,stateReason,url",
3081            ])
3082            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3083        serde_json::from_str(&text)
3084            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
3085    }
3086
3087    pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
3088        let mut issues = Vec::new();
3089        for number in numbers {
3090            let text = self
3091                .gh(&[
3092                    "issue",
3093                    "view",
3094                    &number.to_string(),
3095                    "--json",
3096                    "number,title,body,labels,state,stateReason,url",
3097                ])
3098                .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3099            let issue: Issue = serde_json::from_str(&text)
3100                .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3101            if issue.is_closed() {
3102                crate::log!("issue #{number} is closed, skipping");
3103                continue;
3104            }
3105            issues.push(issue);
3106        }
3107        if issues.is_empty() {
3108            bail!("no open issues to work on");
3109        }
3110        Ok(issues)
3111    }
3112
3113    /// Open items, lowest numbered first, from `min_number` upward.
3114    ///
3115    /// The floor exists because a long lived repository accumulates a tail of
3116    /// old issues nobody is going to get to, and taking the lowest numbered
3117    /// open items means walking straight into them.
3118    fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3119        #[derive(Deserialize)]
3120        struct Row {
3121            number: i64,
3122        }
3123        let text = self.gh(&[
3124            kind,
3125            "list",
3126            "--state",
3127            "open",
3128            "--limit",
3129            &FETCH_CEILING.to_string(),
3130            "--json",
3131            "number",
3132        ])?;
3133        let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
3134        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
3135        numbers.sort_unstable();
3136
3137        let noun = if kind == "issue" { "issues" } else { "PRs" };
3138        let found = numbers.len();
3139        if min_number > 0 {
3140            numbers.retain(|n| *n >= min_number);
3141            let skipped = found - numbers.len();
3142            if skipped > 0 {
3143                crate::log!("{skipped} open {noun} below #{min_number} skipped");
3144            }
3145        }
3146        if found >= FETCH_CEILING {
3147            crate::log!(
3148                "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
3149                 considered."
3150            );
3151        }
3152        if numbers.len() > limit {
3153            crate::log!(
3154                "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
3155                 explicitly.",
3156                numbers.len()
3157            );
3158            numbers.truncate(limit);
3159        }
3160        Ok(numbers)
3161    }
3162
3163    /// Open issues, lowest numbered first. `gh issue list` excludes PRs.
3164    pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3165        self.open_numbers("issue", limit, min_number)
3166    }
3167
3168    pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3169        self.open_numbers("pr", limit, min_number)
3170    }
3171
3172    pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
3173        self.branch_prs(branch, "open").into_iter().next()
3174    }
3175
3176    /// The open pull request for a branch, preserving a failed lookup as an
3177    /// error when the caller is deciding whether a write already landed.
3178    pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
3179        let text = self.gh(&[
3180            "pr",
3181            "list",
3182            "--head",
3183            branch,
3184            "--base",
3185            base,
3186            "--state",
3187            "open",
3188            "--json",
3189            "number,url,title,baseRefName",
3190        ])?;
3191        pr_for_base(&text, branch, base)
3192    }
3193
3194    /// Every pull request opened from this branch, merged and closed ones
3195    /// included, because a commit is preserved by whichever one carries it and
3196    /// that is rarely the newest.
3197    fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
3198        self.branch_prs(branch, "all")
3199    }
3200
3201    fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
3202        let text = self.gh_try(&[
3203            "pr",
3204            "list",
3205            "--head",
3206            branch,
3207            "--state",
3208            state,
3209            "--json",
3210            "number,url,title",
3211        ]);
3212        serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
3213    }
3214
3215    /// Whether a number names an issue or a pull request.
3216    ///
3217    /// `gh issue view` happily returns a pull request when handed its number,
3218    /// so it cannot be used to tell them apart. The issues API carries both and
3219    /// marks a pull request with a `pull_request` key, which is definitive.
3220    pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
3221        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
3222        let text = self
3223            .gh(&[
3224                "api",
3225                &path,
3226                "--jq",
3227                "if .pull_request then \"pr\" else \"issue\" end",
3228            ])
3229            .map_err(|e| {
3230                spar_err!(
3231                    "no issue or pull request #{number} in this repository. {}",
3232                    e.last_line()
3233                )
3234            })?;
3235        match text.trim() {
3236            "pr" => Ok(ItemKind::Pr),
3237            "issue" => Ok(ItemKind::Issue),
3238            other => Err(spar_err!(
3239                "could not tell whether #{number} is an issue or a pull request (got {other:?})"
3240            )),
3241        }
3242    }
3243
3244    /// An open pull request that would close this issue, whoever opened it.
3245    ///
3246    /// spar's own branch naming is checked first because it is exact and cheap.
3247    /// Falling back to GitHub's own issue linkage is what lets spar pick up a
3248    /// pull request a person started on a branch named anything at all.
3249    ///
3250    /// An error rather than `None` when the question could not be asked. The
3251    /// two answers are not interchangeable: "no pull request exists" is the one
3252    /// that makes spar implement over the top of somebody's work and open a
3253    /// second pull request for the same issue.
3254    pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
3255        let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
3256        if let Some(pr) = mine {
3257            return Ok(Some(pr));
3258        }
3259        let listed = match self.gh(&[
3260            "pr",
3261            "list",
3262            "--state",
3263            "open",
3264            "--limit",
3265            &FETCH_CEILING.to_string(),
3266            "--json",
3267            "number,url,title,baseRefName,closingIssuesReferences",
3268        ]) {
3269            Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
3270                spar_err!("could not read the pull request list while looking for #{issue}")
3271            })?,
3272            Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
3273        };
3274        if listed.len() >= FETCH_CEILING {
3275            logwarn!(
3276                "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a                  pull request past it cannot be seen from here."
3277            );
3278        }
3279        let default = self.default_branch(base);
3280        if !base.eq_ignore_ascii_case(&default) {
3281            // GitHub populates closingIssuesReferences only for pull requests
3282            // targeting the default branch, so on any other base the linkage is
3283            // silence rather than an answer.
3284            logwarn!(
3285                "#{issue}: the base branch is `{base}` and the default is `{default}`. GitHub                  links closing issues only on the default branch, so an existing pull request                  for this issue may not be found. Name the pull request instead of the issue if                  there is one."
3286            );
3287        }
3288        Ok(pick_linked_pr(&listed, issue))
3289    }
3290
3291    /// spar's own branch, with a failed lookup kept as a failure.
3292    fn pr_for_branch_failing_closed(
3293        &self,
3294        branch: &str,
3295        base: &str,
3296        issue: i64,
3297    ) -> Result<Option<PrRef>> {
3298        match self.try_pr_for_branch(branch, base) {
3299            Ok(found) => Ok(found),
3300            Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
3301        }
3302    }
3303
3304    /// What to do when GitHub would not say whether a pull request exists.
3305    ///
3306    /// An error, because "no pull request exists" is the answer that makes spar
3307    /// implement from scratch and open a second pull request over somebody's
3308    /// work. The one exception is a repository gh cannot reach at all: there is
3309    /// no pull request to collide with when there is no GitHub repository in
3310    /// play, and every later write would fail anyway. That case is a warning and
3311    /// a `None`, so a run against a plain git remote behaves as it always did.
3312    fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
3313        if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
3314            return Err(spar_err!(
3315                "could not check whether a pull request already exists for #{issue}: {}. \
3316                 Refusing to implement over the top of work that may already be open.",
3317                e.last_line()
3318            ));
3319        }
3320        logwarn!(
3321            "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
3322             whether a pull request already exists for it.",
3323            e.last_line()
3324        );
3325        Ok(())
3326    }
3327
3328    pub fn pr_view(&self, number: i64) -> Result<PrView> {
3329        let text = self.gh(&[
3330            "pr",
3331            "view",
3332            &number.to_string(),
3333            "--json",
3334            "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
3335        ])?;
3336        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
3337    }
3338
3339    fn try_pr_state(&self, number: i64) -> Result<String> {
3340        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
3341        serde_json::from_str::<Value>(text.trim())
3342            .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
3343            .get("state")
3344            .and_then(Value::as_str)
3345            .map(str::to_string)
3346            .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
3347    }
3348
3349    pub fn pr_state(&self, number: i64) -> String {
3350        self.try_pr_state(number).unwrap_or_default()
3351    }
3352
3353    /// The commit currently exposed as a pull request's head.
3354    pub fn pr_head_oid(&self, number: i64) -> Result<String> {
3355        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
3356        let oid = serde_json::from_str::<Value>(&text)
3357            .ok()
3358            .and_then(|value| {
3359                value
3360                    .get("headRefOid")
3361                    .and_then(Value::as_str)
3362                    .map(str::trim)
3363                    .filter(|oid| !oid.is_empty())
3364                    .map(str::to_string)
3365            })
3366            .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
3367        Ok(oid)
3368    }
3369
3370    pub fn create_pr(
3371        &self,
3372        cwd: &Path,
3373        branch: &str,
3374        base: &str,
3375        title: &str,
3376        body: &str,
3377    ) -> Result<PrRef> {
3378        let title = self.record_failed_write(self.clean_title(title))?;
3379        let body = self.record_failed_write(self.clean(body))?;
3380        let mut argv = vec![
3381            "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
3382        ];
3383        if self.drafts != Drafts::Never {
3384            argv.push("--draft");
3385        }
3386        let created = self.gh_at(Some(cwd), &argv);
3387        let found = self.try_pr_for_branch(branch, base);
3388        self.record_write(reconcile_pr_creation(branch, created, found))
3389    }
3390
3391    pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
3392        let body = signed(&self.record_failed_write(self.clean(body))?);
3393        let comments = self.record_failed_write(self.try_issue_comments(number))?;
3394        if has_exact_comment(&comments, &body) {
3395            return Ok(());
3396        }
3397        let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
3398        let result = match posted {
3399            Ok(_) => Ok(()),
3400            Err(post_error) => {
3401                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3402            }
3403        };
3404        self.record_write(result)
3405    }
3406
3407    pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
3408        let body = signed(&self.record_failed_write(self.clean(body))?);
3409        let comments = self.record_failed_write(self.try_issue_comments(number))?;
3410        if has_exact_comment(&comments, &body) {
3411            return Ok(());
3412        }
3413        let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
3414        let result = match posted {
3415            Ok(_) => Ok(()),
3416            Err(post_error) => {
3417                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3418            }
3419        };
3420        self.record_write(result)
3421    }
3422
3423    /// Comment, then close as not planned.
3424    ///
3425    /// Only ever called when both agents independently declined the issue: one
3426    /// agent's opinion is not enough to close somebody's report.
3427    pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
3428        self.comment_issue(number, body)?;
3429        let n = number.to_string();
3430        let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
3431            Ok(_) => Ok(()),
3432            // Older gh builds do not take --reason.
3433            Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
3434                spar_err!(
3435                    "commented on #{number} but could not close it: {}",
3436                    e.last_line()
3437                )
3438            }),
3439        };
3440        self.record_write(closed)
3441    }
3442
3443    /// Replace an issue body, refusing unless it is still byte for byte what
3444    /// the caller read and validating only the fragment spar inserted.
3445    ///
3446    /// The only place spar rewrites text somebody else wrote, so the check is
3447    /// the whole point: an edit computed from a body that has since moved would
3448    /// silently delete whatever moved it. The caller decides whether another
3449    /// attempt is safe for its workflow.
3450    ///
3451    /// Deliberately not through `clean_issue_body`. The body is mostly a
3452    /// person's own prose, and the scrub would rewrite their punctuation while
3453    /// the length budget could truncate the end of a long report. `inserted` is
3454    /// the only text here spar is answerable for, so it still passes through the
3455    /// style gate. The full body travels over stdin because a tracker can be far
3456    /// too long for one argument.
3457    pub fn edit_issue_body(
3458        &self,
3459        number: i64,
3460        expected: &str,
3461        body: &str,
3462        inserted: &str,
3463    ) -> Result<()> {
3464        let cleaned = self.record_failed_write(self.clean(inserted))?;
3465        if cleaned.trim() != inserted.trim() {
3466            return self.record_failed_write(Err(spar_err!(
3467                "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
3468            )));
3469        }
3470        let current = self.record_failed_write(self.issue_body(number))?;
3471        if current != expected {
3472            return self.record_failed_write(Err(spar_err!(
3473                "the body of #{number} changed since it was read, so it was left alone rather \
3474                 than written over."
3475            )));
3476        }
3477        let edited = self.gh_stdin(
3478            &["issue", "edit", &number.to_string(), "--body-file", "-"],
3479            body,
3480        );
3481        let result = match edited {
3482            Ok(_) => Ok(()),
3483            Err(edit_error) => {
3484                reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
3485            }
3486        };
3487        self.record_write(result)
3488    }
3489
3490    /// One issue's body, exactly as GitHub holds it.
3491    pub fn issue_body(&self, number: i64) -> Result<String> {
3492        #[derive(Deserialize)]
3493        struct Row {
3494            #[serde(default)]
3495            body: Option<String>,
3496        }
3497        let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
3498        let row: Row = serde_json::from_str(text.trim())
3499            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3500        Ok(row.body.unwrap_or_default())
3501    }
3502
3503    /// Every open issue with its title and body, in one call.
3504    ///
3505    /// For a screen that has to say something about each of twenty items before
3506    /// anything expensive happens. One call rather than one per issue.
3507    pub fn open_issue_rows(&self) -> Vec<Issue> {
3508        let text = self.gh_try(&[
3509            "issue",
3510            "list",
3511            "--state",
3512            "open",
3513            "--limit",
3514            &FETCH_CEILING.to_string(),
3515            "--json",
3516            "number,title,body,labels,state,stateReason,url",
3517        ]);
3518        serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
3519    }
3520
3521    /// Every open pull request with its size, in one call.
3522    pub fn open_pr_rows(&self) -> Vec<PrRow> {
3523        let text = self.gh_try(&[
3524            "pr",
3525            "list",
3526            "--state",
3527            "open",
3528            "--limit",
3529            &FETCH_CEILING.to_string(),
3530            "--json",
3531            "number,title,changedFiles,additions,deletions",
3532        ]);
3533        serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
3534    }
3535
3536    pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
3537        self.create_issue_apart_from(title, body, None)
3538    }
3539
3540    pub fn create_issue_apart_from(
3541        &self,
3542        title: &str,
3543        body: &str,
3544        apart_from: Option<i64>,
3545    ) -> Result<String> {
3546        let title = self.record_failed_write(self.clean_title(title))?;
3547        let body = self.record_failed_write(self.clean_issue_body(body))?;
3548        let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
3549        let result = match created {
3550            Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
3551            created => {
3552                let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
3553                reconcile_issue_creation(&title, created, found)
3554            }
3555        };
3556        self.record_write(result)
3557    }
3558}
3559
3560/// An issue that already covers what spar was about to file.
3561#[derive(Debug, Clone)]
3562pub struct ExistingIssue {
3563    pub number: i64,
3564    pub url: String,
3565    pub title: String,
3566    pub body: String,
3567    pub open: bool,
3568}
3569
3570impl Repo {
3571    pub(crate) fn try_exact_issue_apart_from(
3572        &self,
3573        title: &str,
3574        body: &str,
3575        apart_from: Option<i64>,
3576    ) -> Result<Option<ExistingIssue>> {
3577        #[derive(Deserialize)]
3578        #[serde(rename_all = "camelCase")]
3579        struct Row {
3580            number: i64,
3581            #[serde(default)]
3582            title: String,
3583            #[serde(default)]
3584            url: String,
3585            #[serde(default)]
3586            body: Option<String>,
3587            #[serde(default)]
3588            state: String,
3589        }
3590
3591        let text = self.gh(&[
3592            "issue",
3593            "list",
3594            "--state",
3595            "all",
3596            "--limit",
3597            "100",
3598            "--json",
3599            "number,title,url,body,state,stateReason",
3600        ])?;
3601        let rows = serde_json::from_str::<Vec<Row>>(text.trim())
3602            .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
3603        Ok(rows
3604            .into_iter()
3605            .filter(|row| Some(row.number) != apart_from)
3606            .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
3607            .map(|row| ExistingIssue {
3608                number: row.number,
3609                url: row.url,
3610                title: row.title,
3611                body: row.body.unwrap_or_default(),
3612                open: row.state.eq_ignore_ascii_case("open"),
3613            }))
3614    }
3615
3616    /// An issue that already describes this defect, however it was worded.
3617    ///
3618    /// Exact title matching let duplicates through: two agents, or two runs a
3619    /// week apart, never word one defect identically. A real run filed two
3620    /// duplicates that way, and each had to be closed by hand afterwards.
3621    /// Titles alone are too thin to match on, so this compares titles and
3622    /// bodies together.
3623    pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
3624        self.find_similar_issue_apart_from(title, body, None)
3625    }
3626
3627    /// The same search, with one issue that cannot be its own duplicate.
3628    ///
3629    /// A tracker's body quotes every item in its checklist, so searching for an
3630    /// item's words matches the tracker before it matches anything else. That
3631    /// would link an item to the issue it is written in.
3632    /// The search a tracker runs, which wants closed issues too: a checklist
3633    /// item matching a closed issue is a done item that wants a link.
3634    pub fn find_similar_issue_apart_from(
3635        &self,
3636        title: &str,
3637        body: &str,
3638        apart_from: Option<i64>,
3639    ) -> Option<ExistingIssue> {
3640        self.try_similar_issue(title, body, apart_from, true)
3641            .ok()
3642            .flatten()
3643    }
3644
3645    /// The search a filing runs, over open issues only.
3646    pub fn try_find_similar_issue_apart_from(
3647        &self,
3648        title: &str,
3649        body: &str,
3650        apart_from: Option<i64>,
3651    ) -> Result<Option<ExistingIssue>> {
3652        self.try_similar_issue(title, body, apart_from, false)
3653    }
3654
3655    /// The same search, preserving lookup failure for a caller about to write.
3656    fn try_similar_issue(
3657        &self,
3658        title: &str,
3659        body: &str,
3660        apart_from: Option<i64>,
3661        include_closed: bool,
3662    ) -> Result<Option<ExistingIssue>> {
3663        #[derive(Deserialize)]
3664        #[serde(rename_all = "camelCase")]
3665        struct Row {
3666            number: i64,
3667            #[serde(default)]
3668            title: String,
3669            #[serde(default)]
3670            url: String,
3671            #[serde(default)]
3672            body: String,
3673            #[serde(default)]
3674            state: String,
3675        }
3676        if title.trim().is_empty() {
3677            return Ok(None);
3678        }
3679        // Search on the title's own words: GitHub's index is the cheap way to
3680        // narrow the field before comparing properly.
3681        let query: String = title
3682            .chars()
3683            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
3684            .take(120)
3685            .collect();
3686        // Open issues only when something is about to be filed. A fuzzy match
3687        // against a closed one is the case worth keeping rather than dropping:
3688        // a defect that was fixed, closed, and has come back is exactly the
3689        // finding a maintainer wants, and "#N covers it and is closed" wrote it
3690        // nowhere. An exact match is still checked against every state, since
3691        // that is a regression of one specific issue rather than a resemblance
3692        // to one, and a tracker still searches closed issues because a
3693        // checklist item matching one is a done item that wants a link.
3694        let state = if include_closed { "all" } else { "open" };
3695        let text = self.gh(&[
3696            "issue",
3697            "list",
3698            "--state",
3699            state,
3700            "--limit",
3701            "100",
3702            "--search",
3703            query.trim(),
3704            "--json",
3705            "number,title,url,body,state,stateReason",
3706        ])?;
3707        let rows: Vec<Row> = serde_json::from_str(text.trim())
3708            .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
3709
3710        Ok(rows
3711            .into_iter()
3712            .filter(|row| Some(row.number) != apart_from)
3713            .find(|row| {
3714                row.title.trim().eq_ignore_ascii_case(title.trim())
3715                    || textsim::covers(title, body, &row.title, &row.body)
3716            })
3717            .map(|row| ExistingIssue {
3718                number: row.number,
3719                url: row.url,
3720                title: row.title,
3721                open: row.state.eq_ignore_ascii_case("open"),
3722                body: row.body,
3723            }))
3724    }
3725
3726    /// Avoid filing a duplicate when a follow-up already exists.
3727    pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
3728        #[derive(Deserialize)]
3729        struct Row {
3730            title: String,
3731            url: String,
3732        }
3733        let needle = title.trim().to_lowercase();
3734        if needle.is_empty() {
3735            return None;
3736        }
3737        // Quotes and newlines would be read as search syntax rather than text.
3738        let query: String = title
3739            .chars()
3740            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
3741            .take(120)
3742            .collect();
3743        let text = self.gh_try(&[
3744            "issue",
3745            "list",
3746            "--state",
3747            "all",
3748            "--limit",
3749            "100",
3750            "--search",
3751            query.trim(),
3752            "--json",
3753            "number,title,url",
3754        ]);
3755        serde_json::from_str::<Vec<Row>>(text.trim())
3756            .ok()?
3757            .into_iter()
3758            .find(|row| row.title.trim().to_lowercase() == needle)
3759            .map(|row| row.url)
3760    }
3761
3762    /// Squash merge, tolerating cleanup failures after a successful merge.
3763    ///
3764    /// Take a pull request out of draft, once the review has converged.
3765    ///
3766    /// Best effort for the remaining workflow. A failure does not discard the
3767    /// review or stop later independent work, but the final write summary
3768    /// reports it and the command returns non-zero.
3769    pub fn mark_ready(&self, number: i64) -> bool {
3770        match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
3771            Ok(_) => true,
3772            Err(e) => {
3773                logdim!(
3774                    "PR #{number} is approved but could not be taken out of draft: {}",
3775                    e.last_line()
3776                );
3777                false
3778            }
3779        }
3780    }
3781
3782    /// `gh pr merge --delete-branch` exits non-zero when it cannot delete the
3783    /// local branch, which happens *after* the merge has already landed.
3784    /// Treating that as a failure reports work as lost when it is not.
3785    pub fn merge_pr(&self, number: i64) -> Result<()> {
3786        let n = number.to_string();
3787        let merged = match self.gh(&merge_pr_args(&n, None, true)) {
3788            Ok(_) => Ok(()),
3789            Err(e) => {
3790                if self.pr_state(number) == "MERGED" {
3791                    logdim!(
3792                        "PR #{number} merged; branch cleanup did not finish: {}",
3793                        e.last_line()
3794                    );
3795                    Ok(())
3796                } else {
3797                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
3798                }
3799            }
3800        };
3801        self.record_write(merged)
3802    }
3803
3804    /// Squash merge only if the pull request still exposes the reviewed head.
3805    pub fn merge_pr_at_head(
3806        &self,
3807        number: i64,
3808        expected_head: &str,
3809        delete_branch: bool,
3810    ) -> Result<()> {
3811        let n = number.to_string();
3812        let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
3813            Ok(_) => Ok(()),
3814            Err(e) => {
3815                if self.pr_state(number) == "MERGED" {
3816                    logdim!(
3817                        "PR #{number} merged; branch cleanup did not finish: {}",
3818                        e.last_line()
3819                    );
3820                    Ok(())
3821                } else {
3822                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
3823                }
3824            }
3825        };
3826        self.record_write(merged)
3827    }
3828
3829    // -- follow-ups -------------------------------------------------------
3830
3831    /// The queue of follow-ups recorded locally rather than filed, which
3832    /// `spar followup` works.
3833    pub fn followups_path(&self) -> PathBuf {
3834        self.root.join(STATE_DIR).join("followups.md")
3835    }
3836
3837    /// What `spar followup` already dealt with, kept beside the queue.
3838    ///
3839    /// Two jobs. It is what stops `append_local_followup` re-recording a
3840    /// follow-up whose entry has since left the queue, which would otherwise
3841    /// turn the file into a ring buffer of things already filed. And it keeps
3842    /// the text of an entry a screening pass ruled stale, so a wrong verdict
3843    /// costs a re-read rather than the only copy of a real defect.
3844    pub fn worked_followups_path(&self) -> PathBuf {
3845        self.root.join(STATE_DIR).join("followups.done.md")
3846    }
3847
3848    /// What `spar checkin` has already answered on one pull request or issue.
3849    /// Where what a run spent is kept.
3850    pub fn spend_path(&self) -> PathBuf {
3851        self.root.join(STATE_DIR).join("state").join("spend.json")
3852    }
3853
3854    /// Append this run's calls to the record.
3855    ///
3856    /// Appended rather than replaced, so a resumed pull request adds to the
3857    /// count of what it has cost so far rather than starting it again, and so
3858    /// anybody charting this has a file to chart.
3859    pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
3860        if calls.is_empty() {
3861            return Ok(());
3862        }
3863        let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
3864            .ok()
3865            .and_then(|text| serde_json::from_str(&text).ok())
3866            .unwrap_or_default();
3867        all.extend(calls.iter().cloned());
3868        if let Some(parent) = self.spend_path().parent() {
3869            let _ = std::fs::create_dir_all(parent);
3870        }
3871        write_json_atomic(&self.spend_path(), &all)
3872    }
3873
3874    /// Who wrote the first draft of each issue, by number.
3875    ///
3876    /// Custody alternates within a pull request and the record of it lives in
3877    /// that pull request's state. This is the other question, which nothing
3878    /// answered: over a queue, which model authored what.
3879    pub fn implementors_path(&self) -> PathBuf {
3880        self.root
3881            .join(STATE_DIR)
3882            .join("state")
3883            .join("implementors.json")
3884    }
3885
3886    pub fn read_implementors(&self) -> BTreeMap<i64, String> {
3887        std::fs::read_to_string(self.implementors_path())
3888            .ok()
3889            .and_then(|text| serde_json::from_str(&text).ok())
3890            .unwrap_or_default()
3891    }
3892
3893    /// Best effort: this is a record for a person, not something the loop reads
3894    /// back, so a failure to write it is not a reason to stop.
3895    pub fn remember_implementor(&self, issue: i64, agent: &str) {
3896        let mut known = self.read_implementors();
3897        known.insert(issue, agent.to_string());
3898        if let Some(parent) = self.implementors_path().parent() {
3899            let _ = std::fs::create_dir_all(parent);
3900        }
3901        if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
3902            logdim!("could not record who implemented #{issue}: {e}");
3903        }
3904    }
3905
3906    /// Where the issues triage disagreed about are remembered.
3907    ///
3908    /// One file rather than one per issue: it is read once per run, before any
3909    /// triage call, to decide what not to ask about again.
3910    pub fn contested_path(&self) -> PathBuf {
3911        self.root
3912            .join(STATE_DIR)
3913            .join("state")
3914            .join("contested.json")
3915    }
3916
3917    /// The issues a person has been asked to decide, by number.
3918    pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
3919        std::fs::read_to_string(self.contested_path())
3920            .ok()
3921            .and_then(|text| serde_json::from_str(&text).ok())
3922            .unwrap_or_default()
3923    }
3924
3925    /// Remember an issue triage could not agree on.
3926    ///
3927    /// Best effort: a run that cannot write this asks again next time, which is
3928    /// what it did before this existed.
3929    pub fn remember_contested(&self, items: &[ContestedItem]) {
3930        if items.is_empty() {
3931            return;
3932        }
3933        let mut known = self.read_contested();
3934        for item in items {
3935            known.insert(
3936                item.issue,
3937                Contested {
3938                    title: item.title.clone(),
3939                    positions: item.positions.clone(),
3940                    reasons: item.reasons.clone(),
3941                },
3942            );
3943        }
3944        if let Some(parent) = self.contested_path().parent() {
3945            let _ = std::fs::create_dir_all(parent);
3946        }
3947        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
3948            logdim!("could not record the contested issues: {e}");
3949        }
3950    }
3951
3952    /// Ask about them again, because a person has acted on them.
3953    pub fn forget_contested(&self, numbers: &[i64]) {
3954        let mut known = self.read_contested();
3955        let before = known.len();
3956        known.retain(|number, _| !numbers.contains(number));
3957        if known.len() == before {
3958            return;
3959        }
3960        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
3961            logdim!("could not update the contested issues: {e}");
3962        }
3963    }
3964
3965    pub fn checkin_state_path(&self, number: i64) -> PathBuf {
3966        self.root
3967            .join(STATE_DIR)
3968            .join("state")
3969            .join(format!("checkin-{number}.json"))
3970    }
3971
3972    /// Append a follow-up to a local note instead of the tracker.
3973    ///
3974    /// Deduplicated on the title, matching the issue path. The body arrives
3975    /// with its provenance already stamped by the caller, so nothing is added
3976    /// here.
3977    ///
3978    /// A write that did not happen is reported as such rather than as a
3979    /// duplicate: the caller settles the point on the strength of this answer,
3980    /// and settling it on a failed write is how a real defect is lost.
3981    ///
3982    /// Both files are checked, because `spar followup` removes an entry from
3983    /// the queue once it has filed it. Checking only the queue would let the
3984    /// next run that rediscovers the same defect append it again, on top of the
3985    /// issue that now exists for it.
3986    pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
3987        let path = self.followups_path();
3988        let heading = format!("## {}", title.trim());
3989        for seen in [&path, &self.worked_followups_path()] {
3990            if let Ok(existing) = std::fs::read_to_string(seen) {
3991                if has_heading(&existing, title) {
3992                    logdim!("follow-up already noted: {title}");
3993                    return Followup::Covered(format!("note: {}", title.trim()));
3994                }
3995            }
3996        }
3997        if let Some(parent) = path.parent() {
3998            let _ = std::fs::create_dir_all(parent);
3999        }
4000        use std::io::Write;
4001        // The caller already stamped the provenance into the body. Adding
4002        // "From #N." here as well printed it twice, in two different wordings.
4003        //
4004        // The marker above the heading is what makes the entry boundary
4005        // unambiguous to the parser, since the body's own sections are written
4006        // at the same heading level as the title.
4007        let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
4008        match std::fs::OpenOptions::new()
4009            .create(true)
4010            .append(true)
4011            .open(&path)
4012        {
4013            Ok(mut file) => match file.write_all(entry.as_bytes()) {
4014                Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
4015                Err(e) => {
4016                    logdim!("could not write {}: {e}", path.display());
4017                    Followup::Failed
4018                }
4019            },
4020            Err(e) => {
4021                logdim!("could not write {}: {e}", path.display());
4022                Followup::Failed
4023            }
4024        }
4025    }
4026
4027    /// Record what `spar followup` did with an entry, and why.
4028    ///
4029    /// Best effort: an archive that could not be written is not a reason to
4030    /// stop, since the entry has already been filed or ruled on.
4031    pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
4032        let path = self.worked_followups_path();
4033        if let Some(parent) = path.parent() {
4034            let _ = std::fs::create_dir_all(parent);
4035        }
4036        use std::io::Write;
4037        let entry = format!(
4038            "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
4039            title.trim(),
4040            body.trim()
4041        );
4042        if let Ok(mut file) = std::fs::OpenOptions::new()
4043            .create(true)
4044            .append(true)
4045            .open(&path)
4046        {
4047            let _ = file.write_all(entry.as_bytes());
4048        }
4049    }
4050
4051    // -- resumable state --------------------------------------------------
4052    //
4053    // Custody cannot be read from GitHub authorship: every agent commits and
4054    // comments as the same git identity, so `author` is always the human who
4055    // ran spar. State is kept on disk by default and can additionally travel in
4056    // a PR comment, which is what lets a run be resumed from another machine.
4057
4058    /// Where a comment spar produced but did not post is kept.
4059    pub fn pending_comment_path(&self, number: i64) -> PathBuf {
4060        self.root
4061            .join(STATE_DIR)
4062            .join("reviews")
4063            .join(format!("pr-{number}.md"))
4064    }
4065
4066    /// Keep a comment spar decided not to post.
4067    ///
4068    /// A dry run that prints and forgets means agreeing with what you read
4069    /// costs a second full review. Saving it makes the whole point of reading
4070    /// it first: look, edit if you like, then post what you already paid for.
4071    pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
4072        let path = self.pending_comment_path(number);
4073        if let Some(parent) = path.parent() {
4074            std::fs::create_dir_all(parent)
4075                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
4076        }
4077        std::fs::write(&path, text)
4078            .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4079        Ok(path)
4080    }
4081
4082    pub fn read_pending_comment(&self, number: i64) -> Option<String> {
4083        std::fs::read_to_string(self.pending_comment_path(number)).ok()
4084    }
4085
4086    pub fn state_path(&self, number: i64) -> PathBuf {
4087        self.root
4088            .join(STATE_DIR)
4089            .join("state")
4090            .join(format!("pr-{number}.json"))
4091    }
4092
4093    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
4094        let path = self.state_path(number);
4095        let text = std::fs::read_to_string(&path).ok()?;
4096        match serde_json::from_str(&text) {
4097            Ok(state) => Some(state),
4098            Err(_) => {
4099                logdim!("could not read {}, starting fresh", path.display());
4100                None
4101            }
4102        }
4103    }
4104
4105    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
4106        if let Some(local) = self.read_local_state(pr.number) {
4107            return Some(local);
4108        }
4109        if self.state_store.writes_pr() {
4110            return self.read_pr_state(pr.number);
4111        }
4112        None
4113    }
4114
4115    pub(crate) fn read_state_for_head(
4116        &self,
4117        pr: &PrView,
4118        actual_head: &str,
4119    ) -> Option<PersistedState> {
4120        let local = self
4121            .state_store
4122            .writes_local()
4123            .then(|| self.read_local_state(pr.number))
4124            .flatten();
4125        let remote = self
4126            .state_store
4127            .writes_pr()
4128            .then(|| self.read_pr_state(pr.number))
4129            .flatten();
4130        let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
4131        if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
4132            self.remember_checkpoint(pr.number, checkpoint);
4133        }
4134        choose_state_for_head(candidates, actual_head)
4135    }
4136
4137    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
4138        self.try_read_pr_state(number).ok().flatten()
4139    }
4140
4141    fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
4142        for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
4143            if let Some(state) = parse_state_comment(&body) {
4144                return Ok(Some(state));
4145            }
4146        }
4147        Ok(None)
4148    }
4149
4150    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4151        let remote_state = if self.state_store.writes_pr() {
4152            self.try_read_pr_state(number)
4153        } else {
4154            Ok(None)
4155        };
4156        self.write_state_after_remote_read(number, state, remote_state)
4157    }
4158
4159    fn write_state_after_remote_read(
4160        &self,
4161        number: i64,
4162        state: &PersistedState,
4163        remote_state: Result<Option<PersistedState>>,
4164    ) -> Result<()> {
4165        let remote_checkpoint = if self.state_store.writes_pr() {
4166            self.record_failed_write(remote_state)?
4167                .map(|saved| saved.checkpoint)
4168                .unwrap_or_default()
4169        } else {
4170            0
4171        };
4172        let local_checkpoint = self
4173            .state_store
4174            .writes_local()
4175            .then(|| self.read_local_state(number))
4176            .flatten()
4177            .map(|saved| saved.checkpoint)
4178            .unwrap_or_default();
4179        let mut stamped = state.clone();
4180        stamped.checkpoint = state
4181            .checkpoint
4182            .max(local_checkpoint)
4183            .max(remote_checkpoint)
4184            .max(self.remembered_checkpoint(number))
4185            .saturating_add(1);
4186        self.remember_checkpoint(number, stamped.checkpoint);
4187        if self.state_store.writes_local() {
4188            write_json_atomic(&self.state_path(number), &stamped)?;
4189        }
4190        if self.state_store.writes_pr() {
4191            self.write_pr_state(number, &stamped)?;
4192        }
4193        Ok(())
4194    }
4195
4196    fn remembered_checkpoint(&self, number: i64) -> u64 {
4197        self.checkpoints
4198            .lock()
4199            .unwrap_or_else(std::sync::PoisonError::into_inner)
4200            .get(&number)
4201            .copied()
4202            .unwrap_or_default()
4203    }
4204
4205    fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
4206        let mut checkpoints = self
4207            .checkpoints
4208            .lock()
4209            .unwrap_or_else(std::sync::PoisonError::into_inner);
4210        let saved = checkpoints.entry(number).or_default();
4211        *saved = (*saved).max(checkpoint);
4212    }
4213
4214    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4215        // Not run through clean(): this is structured data, and scrubbing would
4216        // corrupt refutation text stored in the ledger. It sits inside an
4217        // unclosed HTML comment so GitHub renders it as nothing.
4218        let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
4219        let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
4220        let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
4221        if let Some(id) = comment_id {
4222            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4223            let field = format!("body={body}");
4224            let written = self
4225                .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
4226                .map(|_| ());
4227            return self.record_write(written);
4228        }
4229        let written = self
4230            .gh(&["pr", "comment", &number.to_string(), "--body", &body])
4231            .map(|_| ());
4232        self.record_write(written)
4233    }
4234
4235    /// Top level comments. Works for issues and pull requests alike, because
4236    /// GitHub serves both from the issues endpoint.
4237    ///
4238    /// Nothing when they cannot be read, which suits a reader that is going to
4239    /// go on regardless. A caller deciding whether it has already written here
4240    /// wants `try_issue_comments`, since for that one no comments and no answer
4241    /// are opposite answers.
4242    pub fn issue_comments(&self, number: i64) -> Vec<Value> {
4243        self.try_issue_comments(number).unwrap_or_default()
4244    }
4245
4246    pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
4247        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
4248        try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
4249    }
4250
4251    /// spar's own state comments on a pull request, newest last.
4252    ///
4253    /// Three tests, because the answer decides what gets patched over and what
4254    /// gets deleted. A comment matching the marker loosely, which is what
4255    /// somebody asking "why is there a `<!-- spar:state` block on this PR?"
4256    /// writes, is named in the log and left exactly where it is.
4257    fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
4258        let viewer = self.viewer_login()?.to_string();
4259        let mut mine = Vec::new();
4260        let mut theirs = Vec::new();
4261        for comment in self.try_issue_comments(number)? {
4262            let Some(body) = comment.get("body").and_then(Value::as_str) else {
4263                continue;
4264            };
4265            if !body.contains("spar:state") {
4266                continue;
4267            }
4268            let Some(id) = comment.get("id").and_then(Value::as_i64) else {
4269                continue;
4270            };
4271            let author = comment
4272                .get("user")
4273                .and_then(|u| u.get("login"))
4274                .and_then(Value::as_str)
4275                .unwrap_or_default();
4276            if is_state_comment(body, author, &viewer) {
4277                mine.push((id, body.to_string()));
4278            } else {
4279                theirs.push(format!("@{author}"));
4280            }
4281        }
4282        if !theirs.is_empty() {
4283            logdim!(
4284                "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
4285                 own state block is patched or deleted.",
4286                theirs.len(),
4287                theirs.join(", ")
4288            );
4289        }
4290        Ok(mine)
4291    }
4292
4293    fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
4294        Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
4295    }
4296
4297    /// Drop state once the PR is finished and there is nothing to resume.
4298    pub fn clear_state(&self, number: i64) {
4299        let path = self.state_path(number);
4300        let _ = std::fs::remove_file(&path);
4301        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
4302    }
4303
4304    // -- housekeeping -----------------------------------------------------
4305
4306    /// Remove state files whose PR is merged or closed.
4307    pub fn prune_state(&self) -> Vec<String> {
4308        let base = self.root.join(STATE_DIR).join("state");
4309        let Ok(entries) = std::fs::read_dir(&base) else {
4310            return Vec::new();
4311        };
4312        let mut names: Vec<String> = entries
4313            .flatten()
4314            .filter_map(|e| e.file_name().to_str().map(str::to_string))
4315            .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
4316            .collect();
4317        names.sort();
4318
4319        let mut removed = Vec::new();
4320        for name in names {
4321            let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
4322                continue;
4323            };
4324            if is_finished(&self.pr_state(number)) {
4325                let _ = std::fs::remove_file(base.join(&name));
4326                removed.push(format!("state {name}"));
4327            }
4328        }
4329        removed
4330    }
4331
4332    /// Delete state comments from PRs that are finished.
4333    ///
4334    /// Open PRs are left alone: their state may still be live.
4335    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
4336        #[derive(Deserialize)]
4337        struct Row {
4338            number: i64,
4339        }
4340        let numbers = match numbers {
4341            Some(numbers) => numbers,
4342            None => {
4343                let listed: Result<Vec<i64>> = (|| {
4344                    let text = self.gh(&[
4345                        "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
4346                    ])?;
4347                    let rows = serde_json::from_str::<Vec<Row>>(text.trim())
4348                        .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
4349                    Ok(rows.into_iter().map(|row| row.number).collect())
4350                })();
4351                match self.record_failed_write(listed) {
4352                    Ok(numbers) => numbers,
4353                    Err(e) => {
4354                        logdim!("could not inspect pull requests for state cleanup: {e}");
4355                        return Vec::new();
4356                    }
4357                }
4358            }
4359        };
4360
4361        let mut removed = Vec::new();
4362        for number in numbers {
4363            let state = match self.record_failed_write(self.try_pr_state(number)) {
4364                Ok(state) => state,
4365                Err(e) => {
4366                    logdim!("could not inspect PR #{number} for state cleanup: {e}");
4367                    continue;
4368                }
4369            };
4370            if !is_finished(&state) {
4371                continue;
4372            }
4373            let comments = match self.record_failed_write(self.try_state_comments(number)) {
4374                Ok(comments) => comments,
4375                Err(e) => {
4376                    logdim!("could not inspect state comments on PR #{number}: {e}");
4377                    continue;
4378                }
4379            };
4380            for (id, _) in comments {
4381                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4382                let deleted = self
4383                    .gh(&["api", "-X", "DELETE", &path, "--silent"])
4384                    .map(|_| ());
4385                match self.record_write(deleted) {
4386                    Ok(()) => removed.push(format!("state comment on PR #{number}")),
4387                    Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
4388                }
4389            }
4390        }
4391        removed
4392    }
4393
4394    /// Drop worktrees whose PR is finished, then the branches they left behind.
4395    ///
4396    /// With auto_merge off, which is the default, a run ends at "approved", so
4397    /// nothing would ever clean these up on its own and they accumulate one per
4398    /// run. A stranded worktree also holds its branch checked out, which makes
4399    /// a later `gh pr merge --delete-branch` fail to clean up.
4400    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
4401        let base = self.root.join(WORKTREE_DIR);
4402        let mut removed = Vec::new();
4403        let known = self.known_branches();
4404
4405        if let Ok(entries) = std::fs::read_dir(&base) {
4406            let mut names: Vec<String> = entries
4407                .flatten()
4408                .filter(|e| e.path().is_dir())
4409                .filter_map(|e| e.file_name().to_str().map(str::to_string))
4410                .collect();
4411            names.sort();
4412
4413            for name in names {
4414                // A review worktree is detached and owns no branch, so it is
4415                // tied to the pull request only by its directory name.
4416                if let Some(rest) = name.strip_prefix("review-") {
4417                    let number: i64 = rest.parse().unwrap_or(-1);
4418                    if !(force_all || is_finished(&self.pr_state(number))) {
4419                        continue;
4420                    }
4421                    let path = base.join(&name);
4422                    if force_all {
4423                        let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
4424                            if !belongs {
4425                                return Ok(false);
4426                            }
4427                            let local_ref = review_ref(number);
4428                            if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
4429                                return Ok(false);
4430                            }
4431                            let head = self.head_oid_checked(&path)?;
4432                            let recorded = self
4433                                .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
4434                                .trim()
4435                                .to_string();
4436                            Ok(head == recorded)
4437                        });
4438                        match owned {
4439                            Ok(true) => {}
4440                            Ok(false) => {
4441                                logdim!(
4442                                    "kept {} because no matching SPAR review reference proves \
4443                                     ownership",
4444                                    path.display()
4445                                );
4446                                continue;
4447                            }
4448                            Err(e) => {
4449                                logdim!(
4450                                    "kept {} because review ownership could not be verified: {}",
4451                                    path.display(),
4452                                    e.last_line()
4453                                );
4454                                continue;
4455                            }
4456                        }
4457                    } else {
4458                        if let Err(e) = self.refuse_review_worktree_changes(number) {
4459                            logdim!(
4460                                "kept {} because its review state could not be verified as \
4461                                 disposable: {}",
4462                                path.display(),
4463                                e.last_line()
4464                            );
4465                            continue;
4466                        }
4467                    }
4468                    if force_all {
4469                        if self.remove_worktree_at_force(&path) {
4470                            self.git_try(&["update-ref", "-d", &review_ref(number)]);
4471                        }
4472                    } else {
4473                        self.release_review_worktree(number);
4474                    }
4475                    if !path.exists() {
4476                        removed.push(name);
4477                    }
4478                    continue;
4479                }
4480                let branch = format!("{}{name}", self.branch_prefix);
4481                if !(force_all || self.worktree_is_done(&branch)) {
4482                    continue;
4483                }
4484                if !known.contains_key(&branch) {
4485                    logdim!("kept {branch} because it has no branch record");
4486                    continue;
4487                }
4488                let path = base.join(&name);
4489                if !force_all {
4490                    match self.has_recoverable_work(&path) {
4491                        Ok(true) => {
4492                            logdim!(
4493                                "kept {} because it contains uncommitted changes or ignored files",
4494                                path.display()
4495                            );
4496                            continue;
4497                        }
4498                        Err(e) => {
4499                            logdim!(
4500                                "kept {} because its Git state could not be checked: {}",
4501                                path.display(),
4502                                e.last_line()
4503                            );
4504                            continue;
4505                        }
4506                        Ok(false) => {}
4507                    }
4508                    match self.branch_deletion_is_safe(&branch) {
4509                        Ok(true) => {}
4510                        Ok(false) => {
4511                            logdim!(
4512                                "kept {branch} because no surviving ref preserves its tip or \
4513                                 reflog-only commits"
4514                            );
4515                            continue;
4516                        }
4517                        Err(e) => {
4518                            logdim!(
4519                                "kept {branch} because preservation could not be verified: {}",
4520                                e.last_line()
4521                            );
4522                            continue;
4523                        }
4524                    }
4525                }
4526                let removed_worktree = if force_all {
4527                    self.remove_worktree_at_force(&path)
4528                } else {
4529                    match self.remove_worktree_at(&path) {
4530                        Ok(removed) => removed,
4531                        Err(error) => {
4532                            logdim!(
4533                                "kept {branch} and {} because removal did not reach a confirmed \
4534                                 quiet point: {}",
4535                                path.display(),
4536                                error.last_line()
4537                            );
4538                            false
4539                        }
4540                    }
4541                };
4542                if !removed_worktree {
4543                    continue;
4544                }
4545                if force_all {
4546                    self.git_try(&["branch", "-D", &branch]);
4547                    self.forget_branch(&branch);
4548                } else {
4549                    match self.delete_branch_if_safe(&branch) {
4550                        Ok(true) => self.forget_branch(&branch),
4551                        Ok(false) => logdim!(
4552                            "kept {branch} because its tip or reflog changed before deletion"
4553                        ),
4554                        Err(error) => logdim!(
4555                            "kept {branch} because deletion safety could not be rechecked: {}",
4556                            error.last_line()
4557                        ),
4558                    }
4559                }
4560                removed.push(name);
4561            }
4562        }
4563        removed.extend(self.prune_branches(force_all));
4564        removed
4565    }
4566
4567    /// Delete leftover branches spar created whose worktree is already gone.
4568    ///
4569    /// Deletion is driven by the ledger of branches spar actually created, not
4570    /// by a name pattern. Names default to `issue-N`, which is exactly what a
4571    /// person would call a branch themselves, so a name alone can never
4572    /// establish ownership. This is the data loss guard.
4573    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
4574        let known = self.known_branches();
4575        let branches: Vec<String> = known.keys().cloned().collect();
4576        if branches.is_empty() {
4577            return Vec::new();
4578        }
4579
4580        let checked_out: Vec<String> = self
4581            .git_try(&["worktree", "list", "--porcelain"])
4582            .lines()
4583            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
4584            .collect();
4585
4586        // %(refname:short) is ambiguous when a tag shares the branch name (it
4587        // yields "heads/..."), so take the full ref and strip it here.
4588        let existing: Vec<String> = self
4589            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
4590            .lines()
4591            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
4592            .collect();
4593
4594        let mut removed = Vec::new();
4595        for branch in branches {
4596            if !existing.contains(&branch) {
4597                self.forget_branch(&branch); // already gone, drop the record
4598                continue;
4599            }
4600            if checked_out.contains(&branch) {
4601                continue;
4602            }
4603            if !(force_all || self.worktree_is_done(&branch)) {
4604                continue;
4605            }
4606            if !force_all {
4607                let Some(_record) = known.get(&branch) else {
4608                    continue;
4609                };
4610                match self.branch_deletion_is_safe(&branch) {
4611                    Ok(true) => {}
4612                    Ok(false) => {
4613                        logdim!(
4614                            "kept {branch} because no surviving ref preserves its tip or \
4615                             reflog-only commits"
4616                        );
4617                        continue;
4618                    }
4619                    Err(e) => {
4620                        logdim!(
4621                            "kept {branch} because preservation could not be verified: {}",
4622                            e.last_line()
4623                        );
4624                        continue;
4625                    }
4626                }
4627            }
4628            let deleted = if force_all {
4629                self.git(&["branch", "-D", &branch]).map(|_| true)
4630            } else {
4631                self.delete_branch_if_safe(&branch)
4632            };
4633            match deleted {
4634                Ok(true) => {
4635                    self.forget_branch(&branch);
4636                    removed.push(format!("branch {branch}"));
4637                }
4638                Ok(false) => {
4639                    logdim!("kept {branch} because its tip or reflog changed before deletion");
4640                }
4641                Err(e) => {
4642                    // A branch that silently survives pruning looks like a spar
4643                    // bug, so the name and git's own reason have to be said.
4644                    logdim!("could not delete {branch}: {}", e.last_line());
4645                }
4646            }
4647        }
4648        removed
4649    }
4650
4651    /// True when the PR behind this branch is merged or closed.
4652    fn worktree_is_done(&self, branch: &str) -> bool {
4653        #[derive(Deserialize)]
4654        struct Row {
4655            state: String,
4656        }
4657        let entry = branch
4658            .strip_prefix(self.branch_prefix.as_str())
4659            .unwrap_or(branch);
4660        if let Some(rest) = entry.strip_prefix("pr-") {
4661            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
4662        }
4663        // A split part is the same shape as an issue branch: one branch, whose
4664        // pull requests say whether it is finished. Without it here, a part
4665        // branch is one nothing but `clean --all` would ever remove.
4666        if entry.starts_with("issue-") || entry.starts_with("split-") {
4667            let text = self.gh_try(&[
4668                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
4669            ]);
4670            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4671            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
4672        }
4673        false
4674    }
4675}
4676
4677// ---------------------------------------------------------------------------
4678// Free helpers
4679// ---------------------------------------------------------------------------
4680
4681/// Read attribute files without asking Git to inspect working-tree content.
4682///
4683/// A newly written attribute can select a clean or smudge filter. It must be
4684/// detected before a post-call status, diff, or add command has a chance to run
4685/// that filter in the parent process.
4686pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
4687    let root = std::fs::canonicalize(cwd)
4688        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
4689    let mut files = BTreeMap::new();
4690    let mut visited = BTreeSet::new();
4691    collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
4692    Ok(AttributeState { files })
4693}
4694
4695fn collect_attribute_files(
4696    root: &Path,
4697    repository: &Path,
4698    prefix: &Path,
4699    visited: &mut BTreeSet<PathBuf>,
4700    files: &mut BTreeMap<PathBuf, [u8; 32]>,
4701) -> Result<()> {
4702    let canonical = std::fs::canonicalize(repository)
4703        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
4704    if !visited.insert(canonical) {
4705        bail!("submodule recursion revisited {}", repository.display());
4706    }
4707    let entries = index_entries(repository)?;
4708    let mut paths: BTreeSet<PathBuf> = entries
4709        .iter()
4710        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
4711        .map(|entry| entry.path.clone())
4712        .collect();
4713    let untracked = run_git_bytes(
4714        repository,
4715        &[
4716            "ls-files",
4717            "--others",
4718            "-z",
4719            "--",
4720            ".gitattributes",
4721            ":(glob)**/.gitattributes",
4722        ],
4723    )?;
4724    if !untracked.is_empty() && !untracked.ends_with(&[0]) {
4725        bail!(
4726            "git returned an unterminated attribute-file listing for {}",
4727            repository.display()
4728        );
4729    }
4730    for raw in untracked
4731        .split(|byte| *byte == 0)
4732        .filter(|record| !record.is_empty())
4733    {
4734        paths.insert(safe_git_path(raw, "attribute")?);
4735    }
4736    for path in paths {
4737        let from_root = prefix.join(&path);
4738        let state = attribute_file_fingerprint(&root.join(&from_root))?;
4739        files.insert(from_root, state);
4740    }
4741    for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
4742        let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
4743            continue;
4744        };
4745        collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
4746    }
4747    Ok(())
4748}
4749
4750/// Leave a visible, untracked reason ordinary cleanup can detect on a later
4751/// run even when Git status normalizes the original working-file change away.
4752pub(crate) fn uncertain_worktree_change(
4753    cwd: &Path,
4754    message: impl Into<String>,
4755) -> crate::error::SparError {
4756    let message = message.into();
4757    let marker = write_recovery_marker(cwd, &message);
4758    let note = match marker {
4759        Ok(path) => format!(" Recovery marker: {}.", path.display()),
4760        Err(e) => format!(
4761            " A recovery marker could not be written: {}.",
4762            e.last_line()
4763        ),
4764    };
4765    crate::error::SparError::uncertain_write(format!("{message}{note}"))
4766}
4767
4768fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
4769    use std::sync::atomic::{AtomicU32, Ordering};
4770    static NEXT: AtomicU32 = AtomicU32::new(0);
4771    for _ in 0..1000 {
4772        let serial = NEXT.fetch_add(1, Ordering::Relaxed);
4773        let path = cwd.join(format!(
4774            ".spar-recovery-needed-{}-{serial}",
4775            std::process::id()
4776        ));
4777        let mut options = OpenOptions::new();
4778        options.write(true).create_new(true);
4779        #[cfg(unix)]
4780        {
4781            use std::os::unix::fs::OpenOptionsExt;
4782            options.mode(0o600);
4783        }
4784        match options.open(&path) {
4785            Ok(mut file) => {
4786                file.write_all(detail.as_bytes())
4787                    .and_then(|_| file.write_all(b"\n"))
4788                    .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4789                return Ok(path);
4790            }
4791            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
4792            Err(e) => {
4793                return Err(spar_err!(
4794                    "could not create a recovery marker in {}: {e}",
4795                    cwd.display()
4796                ))
4797            }
4798        }
4799    }
4800    bail!(
4801        "could not choose a free recovery marker name in {}",
4802        cwd.display()
4803    )
4804}
4805
4806/// Build a Git command that cannot launch automatic repository maintenance.
4807///
4808/// A fetch may otherwise prune missing linked worktree registrations. SPAR
4809/// must only remove registrations it has proven it owns.
4810fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
4811    let mut argv = vec![
4812        "git".to_string(),
4813        "-c".to_string(),
4814        "maintenance.auto=false".to_string(),
4815        "-c".to_string(),
4816        "gc.auto=0".to_string(),
4817    ];
4818    argv.extend(args.iter().map(|arg| (*arg).to_string()));
4819    argv
4820}
4821
4822fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
4823    let mut argv = git_without_maintenance_argv(&[]);
4824    argv.extend([
4825        "-c".to_string(),
4826        "core.fsmonitor=".to_string(),
4827        "-c".to_string(),
4828        "commit.gpgsign=false".to_string(),
4829        "-c".to_string(),
4830        "core.hooksPath=/dev/null".to_string(),
4831    ]);
4832    argv.extend(args.iter().map(|arg| (*arg).to_string()));
4833    argv
4834}
4835
4836/// Snapshot every untracked file, including ignored files, without changing
4837/// path bytes.
4838///
4839/// Without an exclude option, Git lists both ordinary and ignored untracked
4840/// entries. Metadata fingerprints make overwriting an existing path observable
4841/// without hashing a potentially multi-gigabyte build tree on every call.
4842pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
4843    let root = std::fs::canonicalize(cwd)
4844        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
4845    let mut files = BTreeMap::new();
4846    let mut ignored = BTreeSet::new();
4847    let mut visited = BTreeSet::new();
4848    collect_untracked_files(
4849        &root,
4850        &root,
4851        Path::new(""),
4852        &mut visited,
4853        &mut files,
4854        &mut ignored,
4855    )?;
4856    Ok(IgnoredState { files, ignored })
4857}
4858
4859fn collect_untracked_files(
4860    root: &Path,
4861    repository: &Path,
4862    prefix: &Path,
4863    visited: &mut BTreeSet<PathBuf>,
4864    files: &mut BTreeMap<PathBuf, UntrackedFile>,
4865    ignored: &mut BTreeSet<PathBuf>,
4866) -> Result<()> {
4867    let canonical = std::fs::canonicalize(repository)
4868        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
4869    if !visited.insert(canonical.clone()) {
4870        bail!("submodule recursion revisited {}", canonical.display());
4871    }
4872    let listed = run_git_bytes(repository, &["ls-files", "--others", "-z"])?;
4873    if !listed.is_empty() && !listed.ends_with(&[0]) {
4874        bail!(
4875            "git returned an unterminated untracked-file list for {}",
4876            repository.display()
4877        );
4878    }
4879
4880    for raw in listed
4881        .split(|byte| *byte == 0)
4882        .filter(|raw| !raw.is_empty())
4883    {
4884        let (relative, nested) = untracked_record(raw, "untracked")?;
4885        let from_root = prefix.join(&relative);
4886        let absolute = root.join(&from_root);
4887        let fingerprint = if nested {
4888            nested_repository_fingerprint(&absolute)?
4889        } else {
4890            ignored_file_fingerprint(&absolute)?
4891        };
4892        if files.insert(from_root.clone(), fingerprint).is_some() {
4893            bail!(
4894                "git returned the untracked path more than once: {:?}",
4895                from_root
4896            );
4897        }
4898    }
4899
4900    let ignored_listed = run_git_bytes(
4901        repository,
4902        &[
4903            "ls-files",
4904            "--others",
4905            "--ignored",
4906            "--exclude-standard",
4907            "-z",
4908        ],
4909    )?;
4910    if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
4911        bail!(
4912            "git returned an unterminated ignored-file list for {}",
4913            repository.display()
4914        );
4915    }
4916    for raw in ignored_listed
4917        .split(|byte| *byte == 0)
4918        .filter(|raw| !raw.is_empty())
4919    {
4920        let (relative, _) = untracked_record(raw, "ignored")?;
4921        let from_root = prefix.join(relative);
4922        if !files.contains_key(&from_root) {
4923            bail!(
4924                "git classified an unlisted path as ignored: {:?}",
4925                from_root
4926            );
4927        }
4928        if !ignored.insert(from_root.clone()) {
4929            bail!(
4930                "git returned the ignored path more than once: {:?}",
4931                from_root
4932            );
4933        }
4934    }
4935
4936    for link in gitlinks(repository)? {
4937        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
4938            continue;
4939        };
4940        collect_untracked_files(
4941            root,
4942            &submodule,
4943            &prefix.join(&link.path),
4944            visited,
4945            files,
4946            ignored,
4947        )?;
4948    }
4949    Ok(())
4950}
4951
4952fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
4953    let argv = git_without_automation_argv(args);
4954    proc::run_bytes(
4955        &argv,
4956        &ExecOpts::new()
4957            .cwd(cwd)
4958            .timeout_secs(30)
4959            .stop_descendants(true),
4960    )
4961}
4962
4963fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
4964    let argv = git_without_automation_argv(args);
4965    proc::run(
4966        &argv,
4967        &ExecOpts::new()
4968            .cwd(cwd)
4969            .timeout_secs(30)
4970            .stop_descendants(true),
4971    )
4972}
4973
4974fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
4975    let path = path.to_str().ok_or_else(|| {
4976        spar_err!(
4977            "cannot verify filtered content for a non-UTF-8 path in {}",
4978            cwd.display()
4979        )
4980    })?;
4981    let path_arg = format!("--path={path}");
4982    let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
4983    Ok(Sha256::digest(bytes).into())
4984}
4985
4986fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
4987    let relative = path_from_git_bytes(raw)?;
4988    if relative.is_absolute()
4989        || relative.components().any(|component| {
4990            matches!(
4991                component,
4992                std::path::Component::ParentDir
4993                    | std::path::Component::RootDir
4994                    | std::path::Component::Prefix(_)
4995            )
4996        })
4997    {
4998        bail!("git returned an unsafe {kind} path: {:?}", relative);
4999    }
5000    Ok(relative)
5001}
5002
5003/// Split one `ls-files --others` record into its path and whether Git reported
5004/// a nested repository rather than a single file.
5005///
5006/// Git never lists the contents of a repository inside the working tree, so a
5007/// checkout parked there, such as another of SPAR's own worktrees, arrives as
5008/// one record for the directory itself ending in a separator. Git writes that
5009/// separator on every platform. Trimming it keeps the recorded path equal to
5010/// the same path seen any other way.
5011fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
5012    let nested = raw.last() == Some(&b'/');
5013    let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
5014    if trimmed.is_empty() {
5015        bail!("git returned an empty {kind} path");
5016    }
5017    Ok((safe_git_path(trimmed, kind)?, nested))
5018}
5019
5020fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
5021    let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
5022    if !listed.is_empty() && !listed.ends_with(&[0]) {
5023        bail!(
5024            "git returned an unterminated index listing for {}",
5025            cwd.display()
5026        );
5027    }
5028    let mut entries = Vec::new();
5029    for record in listed
5030        .split(|byte| *byte == 0)
5031        .filter(|record| !record.is_empty())
5032    {
5033        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5034            bail!(
5035                "git returned a malformed index record for {}",
5036                cwd.display()
5037            );
5038        };
5039        let header = &record[..tab];
5040        let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
5041        if fields.len() != 3 {
5042            bail!(
5043                "git returned a malformed index header for {}",
5044                cwd.display()
5045            );
5046        }
5047        if fields[2] != b"0" {
5048            continue;
5049        }
5050        let mode = std::str::from_utf8(fields[0])
5051            .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
5052            .to_string();
5053        let oid = std::str::from_utf8(fields[1])
5054            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5055            .to_string();
5056        entries.push(IndexEntry {
5057            path: safe_git_path(&record[tab + 1..], "index")?,
5058            mode,
5059            oid,
5060        });
5061    }
5062    Ok(entries)
5063}
5064
5065fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
5066    let untracked = run_git_bytes(
5067        cwd,
5068        &[
5069            "ls-files",
5070            "--others",
5071            "-z",
5072            "--",
5073            ".gitattributes",
5074            ":(glob)**/.gitattributes",
5075        ],
5076    )?;
5077    if !untracked.is_empty() {
5078        return Ok(true);
5079    }
5080
5081    let index = index_entries(cwd)?
5082        .into_iter()
5083        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5084        .map(|entry| (entry.path, (entry.mode, entry.oid)))
5085        .collect::<BTreeMap<_, _>>();
5086    let head = tree_entries(cwd, "HEAD")?
5087        .into_iter()
5088        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5089        .map(|entry| (entry.path, (entry.mode, entry.oid)))
5090        .collect::<BTreeMap<_, _>>();
5091    if index != head {
5092        return Ok(true);
5093    }
5094
5095    let effective = check_attributes(cwd, index.keys().cloned())?;
5096    let config = CheckoutConfig::read(cwd)?;
5097    for (path, (_mode, oid)) in index {
5098        let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
5099            return Ok(true);
5100        };
5101        let attributes = effective
5102            .get(&path)
5103            .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
5104        if allows_expected_crlf(&config, attributes)? {
5105            if worktree.mode == "120000" {
5106                return Ok(true);
5107            }
5108            let (normalized, every_lf_was_crlf) =
5109                normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
5110            if !every_lf_was_crlf || normalized != oid {
5111                return Ok(true);
5112            }
5113        } else if worktree.raw_oid != oid {
5114            return Ok(true);
5115        }
5116    }
5117    Ok(false)
5118}
5119
5120fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
5121    Ok(index_entries(cwd)?
5122        .into_iter()
5123        .filter(|entry| entry.mode == "160000")
5124        .map(|entry| Gitlink {
5125            path: entry.path,
5126            oid: entry.oid,
5127        })
5128        .collect())
5129}
5130
5131fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
5132    let mut tracked = BTreeMap::new();
5133    for entry in index_entries(cwd)? {
5134        if entry.mode == "160000" {
5135            continue;
5136        }
5137        let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
5138        tracked.insert(
5139            entry.path,
5140            TrackedEntry {
5141                index_mode: entry.mode,
5142                index_oid: entry.oid,
5143                worktree,
5144            },
5145        );
5146    }
5147    Ok(tracked)
5148}
5149
5150fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
5151    let metadata = match std::fs::symlink_metadata(path) {
5152        Ok(metadata) => metadata,
5153        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
5154        Err(e) => {
5155            return Err(spar_err!(
5156                "could not inspect tracked file {}: {e}",
5157                path.display()
5158            ))
5159        }
5160    };
5161    let mut fingerprint = Sha256::new();
5162    if metadata.file_type().is_symlink() {
5163        let target = std::fs::read_link(path)
5164            .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
5165        let bytes = os_str_bytes(target.as_os_str())?;
5166        fingerprint.update(b"symlink\0");
5167        fingerprint.update(&bytes);
5168        let content = Sha256::digest(&bytes).into();
5169        return Ok(Some(WorktreeFile {
5170            mode: "120000".to_string(),
5171            #[cfg(unix)]
5172            permissions: 0,
5173            raw_oid: git_blob_oid(oid_len, &bytes)?,
5174            fingerprint: fingerprint.finalize().into(),
5175            content,
5176        }));
5177    }
5178    if !metadata.is_file() {
5179        bail!("tracked path {} is not a file or symlink", path.display());
5180    }
5181
5182    let mut options = OpenOptions::new();
5183    options.read(true);
5184    #[cfg(unix)]
5185    {
5186        use std::os::unix::fs::OpenOptionsExt;
5187        options.custom_flags(libc::O_NOFOLLOW);
5188    }
5189    let mut file = options
5190        .open(path)
5191        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5192    let before = file
5193        .metadata()
5194        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5195    let mode = tracked_file_mode(&before);
5196    #[cfg(unix)]
5197    let permissions = {
5198        use std::os::unix::fs::MetadataExt;
5199        before.mode() & 0o7777
5200    };
5201    fingerprint.update(b"file\0");
5202    fingerprint.update(mode.as_bytes());
5203    #[cfg(unix)]
5204    fingerprint.update(permissions.to_le_bytes());
5205    fingerprint.update(before.len().to_le_bytes());
5206    let mut content = Sha256::new();
5207    let header = format!("blob {}\0", before.len());
5208    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5209    let mut buf = [0u8; 64 * 1024];
5210    loop {
5211        let read = file
5212            .read(&mut buf)
5213            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5214        if read == 0 {
5215            break;
5216        }
5217        fingerprint.update(&buf[..read]);
5218        content.update(&buf[..read]);
5219        object.update(&buf[..read]);
5220    }
5221    let after = file
5222        .metadata()
5223        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5224    if before.len() != after.len()
5225        || before.modified().ok() != after.modified().ok()
5226        || before.permissions() != after.permissions()
5227    {
5228        bail!(
5229            "tracked file {} changed while it was being inspected",
5230            path.display()
5231        );
5232    }
5233    let current = std::fs::symlink_metadata(path)
5234        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5235    if !same_file(&after, &current) {
5236        bail!(
5237            "tracked file {} was replaced while it was being inspected",
5238            path.display()
5239        );
5240    }
5241    Ok(Some(WorktreeFile {
5242        mode,
5243        #[cfg(unix)]
5244        permissions,
5245        raw_oid: object.finish(),
5246        fingerprint: fingerprint.finalize().into(),
5247        content: content.finalize().into(),
5248    }))
5249}
5250
5251fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
5252    let metadata = std::fs::symlink_metadata(path)
5253        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5254    let mut digest = Sha256::new();
5255    if metadata.file_type().is_symlink() {
5256        digest.update(b"symlink\0");
5257        let target = std::fs::read_link(path)
5258            .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
5259        digest.update(os_str_bytes(target.as_os_str())?);
5260        return Ok(digest.finalize().into());
5261    }
5262    if !metadata.is_file() {
5263        bail!("attribute path {} is not a file or symlink", path.display());
5264    }
5265    let mut options = OpenOptions::new();
5266    options.read(true);
5267    #[cfg(unix)]
5268    {
5269        use std::os::unix::fs::OpenOptionsExt;
5270        options.custom_flags(libc::O_NOFOLLOW);
5271    }
5272    let mut file = options
5273        .open(path)
5274        .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5275    let before = file
5276        .metadata()
5277        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5278    digest.update(b"file\0");
5279    let mut buf = [0u8; 64 * 1024];
5280    loop {
5281        let read = file
5282            .read(&mut buf)
5283            .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5284        if read == 0 {
5285            break;
5286        }
5287        digest.update(&buf[..read]);
5288    }
5289    let after = file
5290        .metadata()
5291        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5292    let current = std::fs::symlink_metadata(path)
5293        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5294    if before.len() != after.len()
5295        || before.modified().ok() != after.modified().ok()
5296        || !same_file(&after, &current)
5297    {
5298        bail!(
5299            "attribute file {} changed while it was being inspected",
5300            path.display()
5301        );
5302    }
5303    Ok(digest.finalize().into())
5304}
5305
5306enum ObjectHasher {
5307    Sha1(Sha1),
5308    Sha256(Sha256),
5309}
5310
5311impl ObjectHasher {
5312    fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
5313        let mut hasher = match oid_len {
5314            40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
5315            64 => Self::Sha256(Sha256::new()),
5316            _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
5317        };
5318        hasher.update(header);
5319        Ok(hasher)
5320    }
5321
5322    fn update(&mut self, bytes: &[u8]) {
5323        match self {
5324            Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
5325            Self::Sha256(hasher) => hasher.update(bytes),
5326        }
5327    }
5328
5329    fn finish(self) -> String {
5330        let bytes = match self {
5331            Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
5332            Self::Sha256(hasher) => hasher.finalize().to_vec(),
5333        };
5334        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
5335    }
5336}
5337
5338fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
5339    let header = format!("blob {}\0", bytes.len());
5340    let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
5341    hasher.update(bytes);
5342    Ok(hasher.finish())
5343}
5344
5345fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
5346    let mut first = open_regular_file(path)?;
5347    let first_before = first
5348        .metadata()
5349        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5350    let mut raw_len = 0u64;
5351    let mut crlf_pairs = 0u64;
5352    let mut previous_was_cr = false;
5353    let mut every_lf_was_crlf = true;
5354    let mut buf = [0u8; 64 * 1024];
5355    loop {
5356        let read = first
5357            .read(&mut buf)
5358            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5359        if read == 0 {
5360            break;
5361        }
5362        raw_len = raw_len
5363            .checked_add(read as u64)
5364            .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
5365        for byte in &buf[..read] {
5366            if *byte == b'\n' {
5367                if previous_was_cr {
5368                    crlf_pairs += 1;
5369                } else {
5370                    every_lf_was_crlf = false;
5371                }
5372            }
5373            previous_was_cr = *byte == b'\r';
5374        }
5375    }
5376    let first_after = first
5377        .metadata()
5378        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5379    let current = std::fs::symlink_metadata(path)
5380        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5381    if raw_len != first_before.len()
5382        || !stable_file_metadata(&first_before, &first_after)
5383        || !stable_file_metadata(&first_after, &current)
5384    {
5385        bail!(
5386            "tracked file {} changed while line endings were inspected",
5387            path.display()
5388        );
5389    }
5390
5391    let normalized_len = raw_len
5392        .checked_sub(crlf_pairs)
5393        .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
5394    let header = format!("blob {normalized_len}\0");
5395    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5396    let mut second = open_regular_file(path)?;
5397    let second_before = second
5398        .metadata()
5399        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5400    if !stable_file_metadata(&first_after, &second_before) {
5401        bail!(
5402            "tracked file {} changed between line-ending checks",
5403            path.display()
5404        );
5405    }
5406    let mut pending_cr = false;
5407    loop {
5408        let read = second
5409            .read(&mut buf)
5410            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5411        if read == 0 {
5412            break;
5413        }
5414        for byte in &buf[..read] {
5415            if pending_cr {
5416                if *byte == b'\n' {
5417                    object.update(b"\n");
5418                    pending_cr = false;
5419                    continue;
5420                }
5421                object.update(b"\r");
5422                pending_cr = false;
5423            }
5424            if *byte == b'\r' {
5425                pending_cr = true;
5426            } else {
5427                object.update(std::slice::from_ref(byte));
5428            }
5429        }
5430    }
5431    if pending_cr {
5432        object.update(b"\r");
5433    }
5434    let second_after = second
5435        .metadata()
5436        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5437    let current = std::fs::symlink_metadata(path)
5438        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5439    if !stable_file_metadata(&second_before, &second_after)
5440        || !stable_file_metadata(&second_after, &current)
5441    {
5442        bail!(
5443            "tracked file {} changed while line endings were hashed",
5444            path.display()
5445        );
5446    }
5447    Ok((object.finish(), every_lf_was_crlf))
5448}
5449
5450fn open_regular_file(path: &Path) -> Result<std::fs::File> {
5451    let mut options = OpenOptions::new();
5452    options.read(true);
5453    #[cfg(unix)]
5454    {
5455        use std::os::unix::fs::OpenOptionsExt;
5456        options.custom_flags(libc::O_NOFOLLOW);
5457    }
5458    let file = options
5459        .open(path)
5460        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5461    let metadata = file
5462        .metadata()
5463        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5464    if !metadata.is_file() {
5465        bail!("tracked path {} is not a regular file", path.display());
5466    }
5467    Ok(file)
5468}
5469
5470fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
5471    if !same_file(left, right)
5472        || left.len() != right.len()
5473        || left.modified().ok() != right.modified().ok()
5474        || left.permissions() != right.permissions()
5475    {
5476        return false;
5477    }
5478    #[cfg(unix)]
5479    {
5480        use std::os::unix::fs::MetadataExt;
5481        left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
5482    }
5483    #[cfg(not(unix))]
5484    {
5485        left.created().ok() == right.created().ok()
5486    }
5487}
5488
5489/// How many paths one `check-attr` call is asked about.
5490///
5491/// `check-attr` answers with three NUL fields per attribute per path, so the
5492/// output is several times the input. A whole monorepo in one call is megabytes
5493/// in both directions, and the 30 second timeout below is a timeout on the
5494/// batch. Batching keeps each call small enough to be honest about.
5495const ATTRIBUTE_BATCH: usize = 1000;
5496
5497fn check_attributes(
5498    cwd: &Path,
5499    paths: impl IntoIterator<Item = PathBuf>,
5500) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5501    let paths = paths.into_iter().collect::<BTreeSet<_>>();
5502    if paths.is_empty() {
5503        return Ok(BTreeMap::new());
5504    }
5505    let ordered = paths.into_iter().collect::<Vec<_>>();
5506    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5507    for batch in ordered.chunks(ATTRIBUTE_BATCH) {
5508        values.extend(check_attribute_batch(cwd, batch)?);
5509    }
5510    Ok(values)
5511}
5512
5513fn check_attribute_batch(
5514    cwd: &Path,
5515    batch: &[PathBuf],
5516) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5517    const NAMES: [&str; 6] = [
5518        "filter",
5519        "working-tree-encoding",
5520        "ident",
5521        "text",
5522        "eol",
5523        "crlf",
5524    ];
5525    let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
5526    let mut input = String::new();
5527    for path in &paths {
5528        let path = path.to_str().ok_or_else(|| {
5529            spar_err!(
5530                "cannot inspect attributes for a non-UTF-8 path in {}",
5531                cwd.display()
5532            )
5533        })?;
5534        input.push_str(path);
5535        input.push('\0');
5536    }
5537    let argv = git_without_automation_argv(&[
5538        "check-attr",
5539        "-z",
5540        "--cached",
5541        "--stdin",
5542        "filter",
5543        "working-tree-encoding",
5544        "ident",
5545        "text",
5546        "eol",
5547        "crlf",
5548    ]);
5549    let output = proc::run_bytes(
5550        &argv,
5551        &ExecOpts::new()
5552            .cwd(cwd)
5553            .timeout_secs(30)
5554            .stdin(input)
5555            .stop_descendants(true),
5556    )?;
5557    if !output.is_empty() && !output.ends_with(&[0]) {
5558        bail!(
5559            "git returned an unterminated attribute result for {}",
5560            cwd.display()
5561        );
5562    }
5563    let fields = output
5564        .split(|byte| *byte == 0)
5565        .filter(|field| !field.is_empty())
5566        .collect::<Vec<_>>();
5567    if fields.len() != paths.len() * NAMES.len() * 3 {
5568        bail!(
5569            "git returned an unexpected attribute result for {}",
5570            cwd.display()
5571        );
5572    }
5573    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5574    for record in fields.chunks_exact(3) {
5575        let path = safe_git_path(record[0], "attribute")?;
5576        if !paths.contains(&path) {
5577            bail!(
5578                "git returned attributes for the wrong path in {}",
5579                cwd.display()
5580            );
5581        }
5582        let name = std::str::from_utf8(record[1])
5583            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
5584        let value = std::str::from_utf8(record[2])
5585            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
5586        values
5587            .entry(path)
5588            .or_default()
5589            .insert(name.to_string(), value.to_string());
5590    }
5591    if paths.iter().any(|path| {
5592        values
5593            .get(path)
5594            .is_none_or(|attributes| attributes.len() != NAMES.len())
5595    }) {
5596        bail!(
5597            "git omitted an attribute result for a tracked path in {}",
5598            cwd.display()
5599        );
5600    }
5601    Ok(values)
5602}
5603
5604fn attribute_is_active(value: Option<&String>) -> bool {
5605    !matches!(
5606        value.map(String::as_str),
5607        None | Some("unspecified") | Some("unset")
5608    )
5609}
5610
5611fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
5612    attribute_is_active(values.get("filter"))
5613        || attribute_is_active(values.get("working-tree-encoding"))
5614}
5615
5616/// The checkout settings a per-file line-ending decision depends on.
5617///
5618/// These are constant for a worktree, but the checks that read them run once
5619/// per tracked file, and each read was its own `git config` process. A
5620/// thousand-file repository with no `.gitattributes` takes every one of those
5621/// branches, which is half a minute of process spawning per scan, and the sweep
5622/// before a run scans every finished worktree more than once. Read them here,
5623/// once, and hand them down.
5624struct CheckoutConfig {
5625    autocrlf: Option<String>,
5626    eol: Option<String>,
5627    symlinks: Option<bool>,
5628}
5629
5630impl CheckoutConfig {
5631    fn read(cwd: &Path) -> Result<Self> {
5632        Ok(Self {
5633            autocrlf: git_config_value(cwd, "core.autocrlf")?,
5634            eol: git_config_value(cwd, "core.eol")?,
5635            symlinks: git_config_bool(cwd, "core.symlinks")?,
5636        })
5637    }
5638
5639    fn autocrlf_is_true(&self) -> bool {
5640        self.autocrlf.as_ref().is_some_and(|value| {
5641            matches!(
5642                value.to_ascii_lowercase().as_str(),
5643                "true" | "yes" | "on" | "1"
5644            )
5645        })
5646    }
5647
5648    fn eol_is(&self, wanted: &str) -> bool {
5649        self.eol
5650            .as_ref()
5651            .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
5652    }
5653}
5654
5655fn path_has_ambiguous_transform(
5656    config: &CheckoutConfig,
5657    values: &BTreeMap<String, String>,
5658) -> Result<bool> {
5659    if path_has_external_transform(values)
5660        || attribute_is_active(values.get("ident"))
5661        || attribute_is_active(values.get("crlf"))
5662    {
5663        return Ok(true);
5664    }
5665    let text = values.get("text").map(String::as_str);
5666    let eol = values.get("eol").map(String::as_str);
5667    if text == Some("auto") {
5668        return Ok(true);
5669    }
5670    if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
5671        || !matches!(
5672            eol,
5673            Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
5674        )
5675    {
5676        return Ok(true);
5677    }
5678    if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
5679        return Ok(config.autocrlf_is_true());
5680    }
5681    Ok(false)
5682}
5683
5684fn allows_expected_crlf(
5685    config: &CheckoutConfig,
5686    values: &BTreeMap<String, String>,
5687) -> Result<bool> {
5688    if path_has_external_transform(values)
5689        || attribute_is_active(values.get("ident"))
5690        || attribute_is_active(values.get("crlf"))
5691    {
5692        return Ok(false);
5693    }
5694    let text = values.get("text").map(String::as_str);
5695    let eol = values.get("eol").map(String::as_str);
5696    if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
5697        return Ok(false);
5698    }
5699    if eol == Some("crlf") {
5700        return Ok(true);
5701    }
5702    if text != Some("set") {
5703        return Ok(false);
5704    }
5705    if let Some(autocrlf) = config.autocrlf.as_deref() {
5706        match autocrlf.to_ascii_lowercase().as_str() {
5707            "true" | "yes" | "on" | "1" => return Ok(true),
5708            "input" => return Ok(false),
5709            _ => {}
5710        }
5711    }
5712    if config.eol_is("crlf") {
5713        return Ok(true);
5714    }
5715    #[cfg(windows)]
5716    if config.eol.is_none() || config.eol_is("native") {
5717        return Ok(true);
5718    }
5719    Ok(false)
5720}
5721
5722fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
5723    let argv = git_without_automation_argv(&["config", "--get", key]);
5724    let output = proc::exec(
5725        &argv,
5726        &ExecOpts::new()
5727            .cwd(cwd)
5728            .timeout_secs(30)
5729            .check(false)
5730            .stop_descendants(true),
5731    )?;
5732    match output.code {
5733        0 => Ok(Some(output.stdout.trim().to_string())),
5734        1 => Ok(None),
5735        _ => bail!(
5736            "could not read Git configuration in {}: {}",
5737            cwd.display(),
5738            output.stderr.trim()
5739        ),
5740    }
5741}
5742
5743fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
5744    let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
5745    let output = proc::exec(
5746        &argv,
5747        &ExecOpts::new()
5748            .cwd(cwd)
5749            .timeout_secs(30)
5750            .check(false)
5751            .stop_descendants(true),
5752    )?;
5753    match output.code {
5754        0 if output.stdout.trim() == "true" => Ok(Some(true)),
5755        0 if output.stdout.trim() == "false" => Ok(Some(false)),
5756        0 => bail!(
5757            "git returned an invalid boolean for {key} in {}",
5758            cwd.display()
5759        ),
5760        1 => Ok(None),
5761        _ => bail!(
5762            "could not read Git configuration in {}: {}",
5763            cwd.display(),
5764            output.stderr.trim()
5765        ),
5766    }
5767}
5768
5769#[cfg(unix)]
5770fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
5771    use std::os::unix::fs::PermissionsExt;
5772    if metadata.permissions().mode() & 0o111 == 0 {
5773        "100644".to_string()
5774    } else {
5775        "100755".to_string()
5776    }
5777}
5778
5779#[cfg(not(unix))]
5780fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
5781    "100644".to_string()
5782}
5783
5784fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
5785    let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
5786    if !listed.is_empty() && !listed.ends_with(&[0]) {
5787        bail!(
5788            "git returned an unterminated tree listing for {}",
5789            cwd.display()
5790        );
5791    }
5792    let mut entries = Vec::new();
5793    for record in listed
5794        .split(|byte| *byte == 0)
5795        .filter(|record| !record.is_empty())
5796    {
5797        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5798            bail!("git returned a malformed tree record for {}", cwd.display());
5799        };
5800        let fields = record[..tab]
5801            .split(|byte| *byte == b' ')
5802            .collect::<Vec<_>>();
5803        if fields.len() != 3 {
5804            bail!("git returned a malformed tree header for {}", cwd.display());
5805        }
5806        let mode = std::str::from_utf8(fields[0])
5807            .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
5808            .to_string();
5809        let oid = std::str::from_utf8(fields[2])
5810            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5811            .to_string();
5812        entries.push(IndexEntry {
5813            path: safe_git_path(&record[tab + 1..], "tree")?,
5814            mode,
5815            oid,
5816        });
5817    }
5818    Ok(entries)
5819}
5820
5821fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
5822    Ok(tree_entries(cwd, "HEAD")?
5823        .into_iter()
5824        .filter(|entry| entry.mode == "160000")
5825        .map(|entry| (entry.path, entry.oid))
5826        .collect())
5827}
5828
5829fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
5830    let head = head_gitlinks(cwd)?;
5831    let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
5832        .into_iter()
5833        .map(|link| (link.path, link.oid))
5834        .collect();
5835    let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
5836    paths.extend(index.keys().cloned());
5837    Ok(paths
5838        .into_iter()
5839        .filter(|path| head.get(path) != index.get(path))
5840        .collect())
5841}
5842
5843fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
5844    let path = parent.join(relative);
5845    let metadata = match std::fs::symlink_metadata(&path) {
5846        Ok(metadata) => metadata,
5847        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
5848        Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
5849    };
5850    if !metadata.is_dir() {
5851        bail!("the gitlink at {} is not a directory", path.display());
5852    }
5853    let canonical = std::fs::canonicalize(&path)
5854        .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
5855    if canonical != path {
5856        bail!(
5857            "the gitlink at {} resolves through a symlink",
5858            path.display()
5859        );
5860    }
5861    if !path.join(".git").exists() {
5862        let empty = std::fs::read_dir(&path)
5863            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
5864            .next()
5865            .is_none();
5866        if empty {
5867            return Ok(None);
5868        }
5869        bail!(
5870            "the uninitialized gitlink at {} contains local files",
5871            path.display()
5872        );
5873    }
5874    let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
5875    if inside.trim() != "true" {
5876        bail!("the gitlink at {} is not a worktree", path.display());
5877    }
5878    let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
5879    let top = std::fs::canonicalize(top.trim()).map_err(|e| {
5880        spar_err!(
5881            "could not resolve the gitlink top level at {}: {e}",
5882            path.display()
5883        )
5884    })?;
5885    if top != canonical {
5886        bail!(
5887            "the gitlink at {} belongs to a different worktree",
5888            path.display()
5889        );
5890    }
5891    Ok(Some(canonical))
5892}
5893
5894fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
5895    let root = std::fs::canonicalize(cwd)
5896        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5897    let mut allowed = BTreeSet::from([root.join(".git")]);
5898    let mut repositories = vec![root.clone()];
5899    let mut visited = BTreeSet::new();
5900    while let Some(repository) = repositories.pop() {
5901        let canonical = std::fs::canonicalize(&repository)
5902            .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5903        if !visited.insert(canonical.clone()) {
5904            bail!("submodule recursion revisited {}", canonical.display());
5905        }
5906        for link in gitlinks(&canonical)? {
5907            let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
5908                continue;
5909            };
5910            allowed.insert(submodule.join(".git"));
5911            repositories.push(submodule);
5912        }
5913    }
5914
5915    let scan_root = root.clone();
5916    let mut directories = vec![root];
5917    while let Some(directory) = directories.pop() {
5918        let entries = std::fs::read_dir(&directory)
5919            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5920        for entry in entries {
5921            let entry =
5922                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5923            let path = entry.path();
5924            if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
5925                continue;
5926            }
5927            if entry.file_name() == OsStr::new(".git") {
5928                if !allowed.contains(&path) {
5929                    return Ok(Some(path));
5930                }
5931                continue;
5932            }
5933            let kind = entry
5934                .file_type()
5935                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
5936            if kind.is_dir() {
5937                directories.push(path);
5938            }
5939        }
5940    }
5941    Ok(None)
5942}
5943
5944pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
5945    if let Some(path) = unexpected_nested_git_entry(cwd)? {
5946        bail!(
5947            "the worktree contains an untracked Git entry at {}. It was kept because its \
5948             repository objects are not represented by the outer index.",
5949            path.display()
5950        );
5951    }
5952    let root = std::fs::canonicalize(cwd)
5953        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5954    let mut repositories = BTreeMap::new();
5955    let mut visited = BTreeSet::new();
5956    collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
5957    Ok(GitState { repositories })
5958}
5959
5960fn collect_git_state(
5961    repository: &Path,
5962    prefix: &Path,
5963    visited: &mut BTreeSet<PathBuf>,
5964    repositories: &mut BTreeMap<PathBuf, RepositoryState>,
5965) -> Result<()> {
5966    let canonical = std::fs::canonicalize(repository)
5967        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5968    if !visited.insert(canonical.clone()) {
5969        bail!("submodule recursion revisited {}", canonical.display());
5970    }
5971    let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
5972    let head = head.trim().to_string();
5973    if head.is_empty() {
5974        bail!("git returned an empty head for {}", repository.display());
5975    }
5976    let unsafe_index_flags = unsafe_index_flags(repository)?;
5977    let tracked = tracked_entries(repository)?;
5978    let gitlinks = gitlinks(repository)?;
5979    if repositories
5980        .insert(
5981            prefix.to_path_buf(),
5982            RepositoryState {
5983                head,
5984                unsafe_index_flags,
5985                tracked,
5986                gitlinks: gitlinks
5987                    .iter()
5988                    .map(|link| (link.path.clone(), link.oid.clone()))
5989                    .collect(),
5990            },
5991        )
5992        .is_some()
5993    {
5994        bail!("Git state contains duplicate repository path {:?}", prefix);
5995    }
5996
5997    for link in gitlinks {
5998        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
5999            continue;
6000        };
6001        collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
6002    }
6003    Ok(())
6004}
6005
6006fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
6007    let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
6008    if !listed.is_empty() && !listed.ends_with(&[0]) {
6009        bail!(
6010            "git returned an unterminated index-flag listing for {}",
6011            cwd.display()
6012        );
6013    }
6014    let mut unsafe_records = Vec::new();
6015    for record in listed
6016        .split(|byte| *byte == 0)
6017        .filter(|record| !record.is_empty())
6018    {
6019        if record.len() < 3 || record[1] != b' ' {
6020            bail!(
6021                "git returned a malformed index-flag record for {}",
6022                cwd.display()
6023            );
6024        }
6025        if record[0] != b'H' {
6026            unsafe_records.extend_from_slice(record);
6027            unsafe_records.push(0);
6028        }
6029    }
6030    Ok(unsafe_records)
6031}
6032
6033pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
6034    safe_git_state(cwd).map(|_| ())
6035}
6036
6037pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
6038    let state = git_state(cwd)?;
6039    if let Some((path, _repository)) = state
6040        .repositories
6041        .iter()
6042        .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
6043    {
6044        let label = if path.as_os_str().is_empty() {
6045            cwd.to_path_buf()
6046        } else {
6047            cwd.join(path)
6048        };
6049        bail!(
6050            "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
6051             SPAR cannot prove the working files are unchanged, so it was kept.",
6052            label.display()
6053        );
6054    }
6055    Ok(state)
6056}
6057
6058fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
6059    if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
6060        return Ok(true);
6061    }
6062    let mut visited = BTreeSet::new();
6063    repository_has_recoverable_work_inner(cwd, include_ignored, &mut visited)
6064}
6065
6066fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
6067    let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
6068    let git_dir = PathBuf::from(git_dir.trim());
6069    let git_dir = if git_dir.is_absolute() {
6070        git_dir
6071    } else {
6072        cwd.join(git_dir)
6073    };
6074    let git_dir = std::fs::canonicalize(&git_dir)
6075        .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
6076    match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
6077        Ok(_) => return Ok(true),
6078        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6079        Err(error) => {
6080            return Err(spar_err!(
6081                "could not inspect per-worktree configuration in {}: {error}",
6082                git_dir.display()
6083            ))
6084        }
6085    }
6086
6087    let orig_head = git_dir.join("ORIG_HEAD");
6088    match std::fs::symlink_metadata(&orig_head) {
6089        Ok(metadata) if metadata.is_file() => {
6090            let oid = std::fs::read_to_string(&orig_head)
6091                .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
6092            let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
6093                return Ok(true);
6094            };
6095            if !commit_has_shared_ref(cwd, &commit)? {
6096                return Ok(true);
6097            }
6098        }
6099        Ok(_) => return Ok(true),
6100        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6101        Err(error) => {
6102            return Err(spar_err!(
6103                "could not inspect {}: {error}",
6104                orig_head.display()
6105            ))
6106        }
6107    }
6108
6109    let edit_message = git_dir.join("COMMIT_EDITMSG");
6110    match std::fs::symlink_metadata(&edit_message) {
6111        Ok(metadata) if metadata.is_file() => {
6112            let draft = std::fs::read(&edit_message)
6113                .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
6114            if draft != head_commit_message(cwd)? {
6115                return Ok(true);
6116            }
6117        }
6118        Ok(_) => return Ok(true),
6119        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6120        Err(error) => {
6121            return Err(spar_err!(
6122                "could not inspect {}: {error}",
6123                edit_message.display()
6124            ))
6125        }
6126    }
6127
6128    if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
6129        return Ok(true);
6130    }
6131
6132    let local_refs = run_git_bytes(
6133        cwd,
6134        &[
6135            "for-each-ref",
6136            "--format=%(refname)",
6137            "refs/worktree",
6138            "refs/bisect",
6139            "refs/rewritten",
6140        ],
6141    )?;
6142    if !local_refs.is_empty() {
6143        return Ok(true);
6144    }
6145
6146    for entry in std::fs::read_dir(&git_dir)
6147        .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
6148    {
6149        let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
6150        let known = matches!(
6151            entry.file_name().to_str(),
6152            Some(
6153                "HEAD"
6154                    | "ORIG_HEAD"
6155                    | "COMMIT_EDITMSG"
6156                    | "commondir"
6157                    | "gitdir"
6158                    | "index"
6159                    | "logs"
6160                    | "refs"
6161            )
6162        );
6163        if !known {
6164            return Ok(true);
6165        }
6166    }
6167
6168    let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6169    if !commit_has_shared_ref(cwd, head.trim())? {
6170        return Ok(true);
6171    }
6172    Ok(false)
6173}
6174
6175fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
6176    let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
6177    let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
6178        bail!(
6179            "git returned a commit without a message separator in {}",
6180            cwd.display()
6181        );
6182    };
6183    Ok(commit[split + 2..].to_vec())
6184}
6185
6186fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
6187    let metadata = match std::fs::symlink_metadata(logs) {
6188        Ok(metadata) => metadata,
6189        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6190        Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
6191    };
6192    if !metadata.is_dir() {
6193        return Ok(true);
6194    }
6195    let mut files = Vec::new();
6196    let mut directories = vec![logs.to_path_buf()];
6197    while let Some(directory) = directories.pop() {
6198        for entry in std::fs::read_dir(&directory)
6199            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
6200        {
6201            let entry =
6202                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
6203            let path = entry.path();
6204            let kind = entry
6205                .file_type()
6206                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
6207            if kind.is_dir() {
6208                directories.push(path);
6209            } else if kind.is_file() {
6210                files.push(path);
6211            } else {
6212                return Ok(true);
6213            }
6214        }
6215    }
6216
6217    let mut commits = BTreeSet::new();
6218    for path in files {
6219        if !collect_reflog_commits(cwd, &path, &mut commits)? {
6220            return Ok(true);
6221        }
6222    }
6223    for commit in commits {
6224        if !commit_has_shared_ref(cwd, &commit)? {
6225            return Ok(true);
6226        }
6227    }
6228    Ok(false)
6229}
6230
6231/// Every commit named by one ref's common reflog must survive deletion of that
6232/// ref. Ancestors of a durable current tip survive with the tip; divergent
6233/// entries need another shared ref of their own.
6234fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
6235    let common = common_git_dir(cwd)?;
6236    let reflog = common.join("logs").join(refname);
6237    let metadata = match std::fs::symlink_metadata(&reflog) {
6238        Ok(metadata) => metadata,
6239        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
6240        Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
6241    };
6242    if !metadata.is_file() {
6243        return Ok(false);
6244    }
6245    let mut commits = BTreeSet::new();
6246    if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
6247        return Ok(false);
6248    }
6249    for commit in commits {
6250        if is_ancestor(cwd, &commit, durable_tip)?
6251            || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
6252        {
6253            continue;
6254        }
6255        return Ok(false);
6256    }
6257    Ok(true)
6258}
6259
6260fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
6261    let file = std::fs::File::open(path)
6262        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6263    for line in std::io::BufReader::new(file).lines() {
6264        let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6265        let mut fields = line.splitn(3, ' ');
6266        let Some(old) = fields.next() else {
6267            return Ok(false);
6268        };
6269        let Some(new) = fields.next() else {
6270            return Ok(false);
6271        };
6272        if fields.next().is_none() {
6273            return Ok(false);
6274        }
6275        for oid in [old, new] {
6276            if oid.bytes().all(|byte| byte == b'0') {
6277                continue;
6278            }
6279            let Some(commit) = resolve_optional_commit(cwd, oid)? else {
6280                return Ok(false);
6281            };
6282            commits.insert(commit);
6283        }
6284    }
6285    Ok(true)
6286}
6287
6288fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
6289    let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
6290    let path = PathBuf::from(raw.trim());
6291    let path = if path.is_absolute() {
6292        path
6293    } else {
6294        cwd.join(path)
6295    };
6296    std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
6297}
6298
6299fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
6300    let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
6301    let output = proc::exec(
6302        &argv,
6303        &ExecOpts::new()
6304            .cwd(cwd)
6305            .timeout_secs(30)
6306            .check(false)
6307            .stop_descendants(true),
6308    )?;
6309    match output.code {
6310        0 => Ok(true),
6311        1 => Ok(false),
6312        _ => bail!("{}", proc::failure_message(&argv, &output)),
6313    }
6314}
6315
6316fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
6317    let commit = format!("{oid}^{{commit}}");
6318    let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
6319    let output = proc::exec(
6320        &argv,
6321        &ExecOpts::new()
6322            .cwd(cwd)
6323            .timeout_secs(30)
6324            .check(false)
6325            .stop_descendants(true),
6326    )?;
6327    if output.code != 0 {
6328        return Ok(None);
6329    }
6330    let oid = output.stdout.trim();
6331    if oid.is_empty() {
6332        return Ok(None);
6333    }
6334    Ok(Some(oid.to_string()))
6335}
6336
6337fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
6338    commit_has_shared_ref_except(cwd, oid, None)
6339}
6340
6341fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
6342    let contains = format!("--contains={oid}");
6343    let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
6344    Ok(shared.split(|byte| *byte == b'\n').any(|record| {
6345        !record.is_empty()
6346            && !record.starts_with(b"refs/worktree/")
6347            && !record.starts_with(b"refs/bisect/")
6348            && !record.starts_with(b"refs/rewritten/")
6349            && exclude.is_none_or(|excluded| record != excluded.as_bytes())
6350    }))
6351}
6352
6353/// Whether removing the worktree would take away an untracked file somebody
6354/// might want back.
6355///
6356/// Ordinary untracked files always count. So does an ignored file outside the
6357/// known build and cache directories, because an ignored path is only a path
6358/// Git was told not to track, which is where a local `.env` lives as readily as
6359/// compiler output.
6360///
6361/// Recognized build and cache output does not. A managed commit already leaves
6362/// it out rather than treating it as work, and the command that wrote it writes
6363/// it again. Counting it kept every worktree whose tests or build had run,
6364/// which is nearly all of them, so a merged pull request still left its
6365/// checkout behind. A repository nested in that output is somebody else's
6366/// history and counts whatever it sits under.
6367fn has_untracked_work_worth_keeping(cwd: &Path) -> Result<bool> {
6368    let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
6369    if !ordinary.is_empty() {
6370        return Ok(true);
6371    }
6372    let listed = untracked_listing(
6373        cwd,
6374        &[
6375            "ls-files",
6376            "--others",
6377            "--ignored",
6378            "--exclude-standard",
6379            "-z",
6380        ],
6381    )?;
6382    for raw in listed {
6383        let (path, nested) = untracked_record(&raw, "ignored")?;
6384        if nested || !is_generated_artifact(&path) {
6385            return Ok(true);
6386        }
6387    }
6388    Ok(false)
6389}
6390
6391fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
6392    let listed = run_git_bytes(cwd, args)?;
6393    if !listed.is_empty() && !listed.ends_with(&[0]) {
6394        bail!(
6395            "git returned an unterminated untracked-file list for {}",
6396            cwd.display()
6397        );
6398    }
6399    Ok(listed
6400        .split(|byte| *byte == 0)
6401        .filter(|raw| !raw.is_empty())
6402        .map(|raw| raw.to_vec())
6403        .collect())
6404}
6405
6406fn repository_has_recoverable_work_inner(
6407    cwd: &Path,
6408    include_ignored: bool,
6409    visited: &mut BTreeSet<PathBuf>,
6410) -> Result<bool> {
6411    let canonical = std::fs::canonicalize(cwd)
6412        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6413    if !visited.insert(canonical.clone()) {
6414        bail!("submodule recursion revisited {}", canonical.display());
6415    }
6416    if include_ignored && has_untracked_work_worth_keeping(cwd)? {
6417        return Ok(true);
6418    }
6419    if !unsafe_index_flags(cwd)?.is_empty() {
6420        return Ok(true);
6421    }
6422    if attributes_may_be_modified(cwd)? {
6423        return Ok(true);
6424    }
6425    if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
6426        return Ok(true);
6427    }
6428    if include_ignored {
6429        let index = index_entries(cwd)?
6430            .into_iter()
6431            .map(|entry| (entry.path, (entry.mode, entry.oid)))
6432            .collect::<BTreeMap<_, _>>();
6433        let head = tree_entries(cwd, "HEAD")?
6434            .into_iter()
6435            .map(|entry| (entry.path, (entry.mode, entry.oid)))
6436            .collect::<BTreeMap<_, _>>();
6437        if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
6438            return Ok(true);
6439        }
6440        let tracked = tracked_entries(cwd)?;
6441        let effective = check_attributes(cwd, tracked.keys().cloned())?;
6442        let config = CheckoutConfig::read(cwd)?;
6443        for (path, entry) in tracked {
6444            let Some(worktree) = entry.worktree else {
6445                return Ok(true);
6446            };
6447            let attributes = effective.get(&path).ok_or_else(|| {
6448                spar_err!("git omitted attributes for {}", cwd.join(&path).display())
6449            })?;
6450            if path_has_ambiguous_transform(&config, attributes)? {
6451                return Ok(true);
6452            }
6453            let symlink_file = entry.index_mode == "120000"
6454                && worktree.mode == "100644"
6455                && worktree.raw_oid == entry.index_oid
6456                && config.symlinks == Some(false);
6457            if worktree.mode != entry.index_mode && !symlink_file {
6458                return Ok(true);
6459            }
6460            if entry.index_mode == "120000" {
6461                if worktree.raw_oid != entry.index_oid {
6462                    return Ok(true);
6463                }
6464                continue;
6465            }
6466            #[cfg(unix)]
6467            {
6468                let expected = if entry.index_mode == "100755" {
6469                    0o755
6470                } else {
6471                    0o644
6472                };
6473                if worktree.permissions != expected {
6474                    return Ok(true);
6475                }
6476            }
6477            if allows_expected_crlf(&config, attributes)? {
6478                let (normalized, every_lf_was_crlf) =
6479                    normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
6480                if !every_lf_was_crlf || normalized != entry.index_oid {
6481                    return Ok(true);
6482                }
6483            } else if worktree.raw_oid != entry.index_oid {
6484                return Ok(true);
6485            }
6486        }
6487    } else {
6488        let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
6489        if !run_git_bytes(cwd, &args)?.is_empty() {
6490            return Ok(true);
6491        }
6492    }
6493    for link in gitlinks(cwd)? {
6494        let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
6495            continue;
6496        };
6497        // Git stores a linked worktree's initialized submodule objects under
6498        // that worktree's administrative directory. Ordinary removal cannot
6499        // prove a local submodule commit exists anywhere else, even when both
6500        // working trees look clean.
6501        if include_ignored {
6502            return Ok(true);
6503        }
6504        let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6505        if head.trim() != link.oid {
6506            return Ok(true);
6507        }
6508        if repository_has_recoverable_work_inner(&submodule, include_ignored, visited)? {
6509            return Ok(true);
6510        }
6511    }
6512    Ok(false)
6513}
6514
6515pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
6516    repository_has_recoverable_work(cwd, false)
6517}
6518
6519fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
6520    let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
6521    Ok(!run_git_bytes(cwd, &args)?.is_empty())
6522}
6523
6524#[cfg(unix)]
6525fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6526    use std::os::unix::ffi::OsStringExt;
6527    Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
6528}
6529
6530#[cfg(not(unix))]
6531fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6532    String::from_utf8(raw.to_vec())
6533        .map(PathBuf::from)
6534        .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
6535}
6536
6537/// Fingerprint the directory of a nested repository without reading inside it.
6538///
6539/// The files under it are that repository's, not this one's. They are recorded
6540/// against its own baseline whenever SPAR works there, and a run of its own may
6541/// legitimately add or remove entries while this call is in flight, so the
6542/// volatile directory fields stay out of the fingerprint. Identity and type
6543/// remain, which is what makes deleting the checkout, or replacing it with a
6544/// file, observable from the outer worktree.
6545fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
6546    let metadata = std::fs::symlink_metadata(path).map_err(|e| {
6547        spar_err!(
6548            "could not inspect the nested repository at {}: {e}",
6549            path.display()
6550        )
6551    })?;
6552    if !metadata.is_dir() {
6553        bail!(
6554            "git reported {} as a nested repository, but it is not a directory",
6555            path.display()
6556        );
6557    }
6558    if std::fs::symlink_metadata(path.join(".git")).is_err() {
6559        bail!(
6560            "git reported {} as a nested repository, but it has no Git entry",
6561            path.display()
6562        );
6563    }
6564    #[cfg(unix)]
6565    {
6566        use std::os::unix::fs::MetadataExt;
6567        Ok(UntrackedFile {
6568            kind: 3,
6569            len: 0,
6570            modified: None,
6571            created: metadata.created().ok(),
6572            readonly: metadata.permissions().readonly(),
6573            symlink_target: None,
6574            device: metadata.dev(),
6575            inode: metadata.ino(),
6576            mode: metadata.mode(),
6577            change_seconds: 0,
6578            change_nanoseconds: 0,
6579        })
6580    }
6581    #[cfg(not(unix))]
6582    {
6583        Ok(UntrackedFile {
6584            kind: 3,
6585            len: 0,
6586            modified: None,
6587            created: metadata.created().ok(),
6588            readonly: metadata.permissions().readonly(),
6589            symlink_target: None,
6590        })
6591    }
6592}
6593
6594fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
6595    let metadata = std::fs::symlink_metadata(path)
6596        .map_err(|e| spar_err!("could not inspect untracked file {}: {e}", path.display()))?;
6597    let kind = if metadata.file_type().is_symlink() {
6598        2
6599    } else if metadata.is_file() {
6600        1
6601    } else {
6602        bail!(
6603            "untracked path {} is not a regular file or symlink",
6604            path.display()
6605        );
6606    };
6607    let symlink_target = if kind == 2 {
6608        let target = std::fs::read_link(path)
6609            .map_err(|e| spar_err!("could not read untracked symlink {}: {e}", path.display()))?;
6610        Some(os_str_bytes(target.as_os_str())?)
6611    } else {
6612        None
6613    };
6614    #[cfg(unix)]
6615    {
6616        use std::os::unix::fs::MetadataExt;
6617        Ok(UntrackedFile {
6618            kind,
6619            len: metadata.len(),
6620            modified: metadata.modified().ok(),
6621            created: metadata.created().ok(),
6622            readonly: metadata.permissions().readonly(),
6623            symlink_target,
6624            device: metadata.dev(),
6625            inode: metadata.ino(),
6626            mode: metadata.mode(),
6627            change_seconds: metadata.ctime(),
6628            change_nanoseconds: metadata.ctime_nsec(),
6629        })
6630    }
6631    #[cfg(not(unix))]
6632    {
6633        Ok(UntrackedFile {
6634            kind,
6635            len: metadata.len(),
6636            modified: metadata.modified().ok(),
6637            created: metadata.created().ok(),
6638            readonly: metadata.permissions().readonly(),
6639            symlink_target,
6640        })
6641    }
6642}
6643
6644#[cfg(unix)]
6645fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6646    use std::os::unix::ffi::OsStrExt;
6647    Ok(value.as_bytes().to_vec())
6648}
6649
6650#[cfg(not(unix))]
6651fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6652    value
6653        .to_str()
6654        .map(|value| value.as_bytes().to_vec())
6655        .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
6656}
6657
6658#[cfg(unix)]
6659fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6660    use std::os::unix::fs::MetadataExt;
6661    right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
6662}
6663
6664#[cfg(not(unix))]
6665fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6666    right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
6667}
6668
6669#[derive(Debug, Clone, serde::Serialize, Deserialize)]
6670pub struct BranchRecord {
6671    pub kind: String,
6672    pub number: i64,
6673}
6674
6675/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
6676/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
6677pub fn review_ref(number: i64) -> String {
6678    format!("refs/spar/pr-{number}")
6679}
6680
6681pub fn is_finished(state: &str) -> bool {
6682    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
6683}
6684
6685/// Write text through a temporary file and rename, so a kill cannot leave a
6686/// truncated file behind.
6687///
6688/// The follow-up queue is the one file spar rewrites in place rather than
6689/// appends to, and a truncated queue is lost work: what it held was never
6690/// written anywhere else.
6691pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
6692    if let Some(parent) = path.parent() {
6693        std::fs::create_dir_all(parent)
6694            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6695    }
6696    // The extension defaults to `json` so `clear_state`, which removes a
6697    // leftover `pr-N.json.tmp` by name, keeps finding the one this wrote.
6698    let tmp = path.with_extension(format!(
6699        "{}.tmp",
6700        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
6701    ));
6702    std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
6703    std::fs::rename(&tmp, path)
6704        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
6705    Ok(())
6706}
6707
6708/// Whether a follow-up file already has an entry under this exact title.
6709///
6710/// Whole heading lines, case insensitively. A substring check answered yes
6711/// for "Retry loop" against "## Retry loop never terminates", which is a
6712/// different defect, and the new one was dropped with a line saying it was
6713/// already noted.
6714pub fn has_heading(text: &str, title: &str) -> bool {
6715    let wanted = title.trim().to_lowercase();
6716    if wanted.is_empty() {
6717        return false;
6718    }
6719    text.lines()
6720        .filter_map(|line| line.trim().strip_prefix("## "))
6721        .any(|heading| heading.trim().to_lowercase() == wanted)
6722}
6723
6724/// Write JSON through a temporary file and rename, so a kill cannot leave a
6725/// truncated state file behind.
6726pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
6727    write_text_atomic(path, &serde_json::to_string_pretty(value)?)
6728}
6729
6730/// One row of `gh pr list --json number,url,title,baseRefName,closingIssuesReferences`.
6731#[derive(Deserialize)]
6732#[serde(rename_all = "camelCase")]
6733pub struct LinkedPrRow {
6734    number: i64,
6735    #[serde(default)]
6736    url: String,
6737    #[serde(default)]
6738    title: String,
6739    #[serde(default)]
6740    closing_issues_references: Vec<IssueRef>,
6741}
6742
6743/// The listed pull requests, or `None` when the payload could not be read.
6744///
6745/// Separated from the gh call so the real payload shape can be tested, and kept
6746/// distinct from an empty list, because silently failing to parse would look
6747/// exactly like "no pull request exists", which is the answer that makes spar
6748/// implement over the top of somebody's work.
6749pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
6750    serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
6751}
6752
6753/// Among the listed pull requests, the first that would close `issue`.
6754pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
6755    rows.iter()
6756        .find(|row| {
6757            row.closing_issues_references
6758                .iter()
6759                .any(|linked| linked.number == issue)
6760        })
6761        .map(|row| PrRef {
6762            number: row.number,
6763            url: row.url.clone(),
6764            title: row.title.clone(),
6765        })
6766}
6767
6768/// Both halves together, for the tests that hold the real payload.
6769pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
6770    pick_linked_pr(&linked_pr_rows(json)?, issue)
6771}
6772
6773/// Flatten whatever `gh api --paginate` printed into a list of comments.
6774///
6775/// Current gh merges array pages into one array. Older builds concatenated one
6776/// document per page. A streaming parser reads either, and unlike splitting the
6777/// text on a bracket pair it cannot be fooled by a comment body that happens to
6778/// contain one, which would otherwise make a resume silently start over.
6779fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
6780    if text.trim().is_empty() {
6781        return Err(spar_err!("GitHub returned no comment data"));
6782    }
6783    let mut out = Vec::new();
6784    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
6785        match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
6786            Value::Array(items) => out.extend(items),
6787            _ => return Err(spar_err!("unexpected non-array comment page")),
6788        }
6789    }
6790    Ok(out)
6791}
6792
6793pub fn parse_comment_pages(text: &str) -> Vec<Value> {
6794    let mut out = Vec::new();
6795    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
6796        match value {
6797            Ok(Value::Array(items)) => out.extend(items),
6798            Ok(other) => out.push(other),
6799            Err(_) => break,
6800        }
6801    }
6802    out
6803}
6804
6805/// Whether a comment is a state block spar itself wrote.
6806///
6807/// The body has to open with the marker, so a person quoting it in a sentence
6808/// is not one; it has to parse, so a half-quoted block is not one; and it has
6809/// to be the viewer's own comment, the way `comments.rs` already tells spar's
6810/// writes apart. Anything less and `write_pr_state` patches somebody's comment
6811/// with JSON, and `spar clean --pr-state` later deletes it.
6812pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
6813    body.trim_start().starts_with(STATE_MARKER)
6814        && !viewer.trim().is_empty()
6815        && author.trim().eq_ignore_ascii_case(viewer.trim())
6816        && parse_state_comment(body).is_some()
6817}
6818
6819/// Extract the payload from a state comment. The marker is followed by JSON and
6820/// terminated with `-->`.
6821pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
6822    let marker = body.find(STATE_MARKER)?;
6823    let start = body[marker..].find('{')? + marker;
6824    let end = body.rfind('}')?;
6825    if end <= start {
6826        return None;
6827    }
6828    match serde_json::from_str(&body[start..=end]) {
6829        Ok(state) => Some(state),
6830        Err(_) => {
6831            logdim!("found a spar state comment but could not parse it");
6832            None
6833        }
6834    }
6835}
6836
6837fn choose_state_for_head(
6838    candidates: Vec<PersistedState>,
6839    actual_head: &str,
6840) -> Option<PersistedState> {
6841    let matching: Vec<PersistedState> = candidates
6842        .iter()
6843        .filter(|state| state.pr_head == actual_head)
6844        .cloned()
6845        .collect();
6846    if !matching.is_empty() {
6847        return newest_state(matching);
6848    }
6849    newest_state(candidates)
6850}
6851
6852fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
6853    candidates.into_iter().reduce(|best, candidate| {
6854        if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
6855            candidate
6856        } else {
6857            // The local candidate is supplied first. Keeping the first exact
6858            // tie recovers correctly from a local write followed by a failed
6859            // pull request state update, including legacy states with no
6860            // checkpoint field.
6861            best
6862        }
6863    })
6864}
6865
6866/// Where this binary lives, so `git filter-branch` can call back into it.
6867///
6868/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
6869/// tests, whose `current_exe` is the test harness rather than spar, and for
6870/// anyone who ships spar behind a wrapper script.
6871pub fn self_binary() -> Result<PathBuf> {
6872    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
6873        let path = PathBuf::from(path);
6874        if proc::is_executable(&path) {
6875            return Ok(path);
6876        }
6877        bail!(
6878            "SPAR_SELF_BIN is set to {}, which is not executable",
6879            path.display()
6880        );
6881    }
6882    std::env::current_exe()
6883        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
6884}
6885
6886fn bool_env(value: bool) -> &'static str {
6887    if value {
6888        "1"
6889    } else {
6890        "0"
6891    }
6892}
6893
6894/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
6895/// shell command, and an install path with a space in it is not exotic.
6896pub fn sh_quote(text: &str) -> String {
6897    format!("'{}'", text.replace('\'', r"'\''"))
6898}
6899
6900/// Style rules for the `scrub-filter` subcommand, which runs in a child process
6901/// spawned by git and so cannot see the parent's config.
6902pub fn style_from_env() -> Style {
6903    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
6904    Style {
6905        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
6906        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
6907        ..Style::permissive()
6908    }
6909}
6910
6911#[cfg(test)]
6912mod tests {
6913    use super::*;
6914    use crate::config::StateStore;
6915    use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
6916    use std::process::Command;
6917
6918    fn repo_for_titles() -> Repo {
6919        Repo {
6920            root: PathBuf::from("/nonexistent"),
6921            style: Style::default(),
6922            branch_prefix: String::new(),
6923            state_store: StateStore::Local,
6924            followups: crate::config::Followups::Issues,
6925            drafts: Drafts::Never,
6926            viewer: OnceLock::new(),
6927            checkpoints: Mutex::new(BTreeMap::new()),
6928            writes: WriteStats::default(),
6929        }
6930    }
6931
6932    #[test]
6933    fn write_results_accumulate_for_the_run() {
6934        let repo = repo_for_titles();
6935
6936        let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
6937        let _: std::result::Result<(), ()> = repo.record_write(Err(()));
6938
6939        assert_eq!(
6940            WriteSummary {
6941                attempted: 2,
6942                failed: 1,
6943            },
6944            repo.write_summary()
6945        );
6946    }
6947
6948    #[test]
6949    fn only_failed_write_preflights_join_the_summary() {
6950        let repo = repo_for_titles();
6951
6952        let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
6953        let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
6954
6955        assert_eq!(
6956            WriteSummary {
6957                attempted: 1,
6958                failed: 1,
6959            },
6960            repo.write_summary()
6961        );
6962    }
6963
6964    #[test]
6965    fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
6966        let repo = repo_for_titles();
6967
6968        assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
6969        assert_eq!(
6970            WriteSummary {
6971                attempted: 1,
6972                failed: 1,
6973            },
6974            repo.write_summary()
6975        );
6976    }
6977
6978    #[test]
6979    fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
6980        let mut repo = repo_for_titles();
6981        repo.followups = Followups::Local;
6982
6983        assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
6984        assert_eq!(WriteSummary::default(), repo.write_summary());
6985    }
6986
6987    #[test]
6988    fn a_failed_remote_state_read_stops_before_state_mutation() {
6989        let root = std::env::temp_dir().join(format!(
6990            "spar-state-preflight-{}-{}",
6991            std::process::id(),
6992            std::time::SystemTime::now()
6993                .duration_since(std::time::UNIX_EPOCH)
6994                .unwrap()
6995                .as_nanos()
6996        ));
6997        std::fs::create_dir_all(&root).unwrap();
6998        let _fixture = ReviewFixture { root: root.clone() };
6999        let mut repo = repo_for_titles();
7000        repo.root = root;
7001        repo.state_store = StateStore::Both;
7002        let state = PersistedState {
7003            version: 1,
7004            checkpoint: 4,
7005            round: 2,
7006            next_actor: "a".into(),
7007            status: Status::Pending,
7008            pr_head: "abc123".into(),
7009            ledger: Ledger::new(),
7010            filed: Vec::new(),
7011            open_findings: Vec::new(),
7012            disputes: Vec::new(),
7013            noted: Vec::new(),
7014        };
7015
7016        let error = repo
7017            .write_state_after_remote_read(
7018                7,
7019                &state,
7020                Err(crate::error::SparError::new("state comments unavailable")),
7021            )
7022            .unwrap_err();
7023
7024        assert!(error.to_string().contains("state comments unavailable"));
7025        assert!(!repo.state_path(7).exists());
7026        assert_eq!(0, repo.remembered_checkpoint(7));
7027        assert_eq!(
7028            WriteSummary {
7029                attempted: 1,
7030                failed: 1,
7031            },
7032            repo.write_summary()
7033        );
7034    }
7035
7036    #[test]
7037    fn only_known_build_and_cache_directories_are_generated_artifacts() {
7038        assert!(is_generated_artifact(Path::new("target/debug/artifact")));
7039        assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
7040        assert!(is_generated_artifact(Path::new(
7041            "package/node_modules/dependency/file.js"
7042        )));
7043        assert!(!is_generated_artifact(Path::new(
7044            "distribution/required-package.js"
7045        )));
7046        assert!(!is_generated_artifact(Path::new(
7047            "generated/required-fixture.txt"
7048        )));
7049        assert!(!is_generated_artifact(Path::new("local.env")));
7050    }
7051
7052    struct ReviewFixture {
7053        root: PathBuf,
7054    }
7055
7056    impl Drop for ReviewFixture {
7057        fn drop(&mut self) {
7058            let _ = std::fs::remove_dir_all(&self.root);
7059        }
7060    }
7061
7062    fn test_git(cwd: &Path, args: &[&str]) -> String {
7063        let output = Command::new("git")
7064            .args(args)
7065            .current_dir(cwd)
7066            .output()
7067            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
7068        assert!(
7069            output.status.success(),
7070            "git {args:?} failed: {}",
7071            String::from_utf8_lossy(&output.stderr)
7072        );
7073        String::from_utf8_lossy(&output.stdout).into_owned()
7074    }
7075
7076    fn review_fixture(
7077        tag: &str,
7078        number: i64,
7079    ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
7080        use std::sync::atomic::{AtomicU32, Ordering};
7081        static NEXT: AtomicU32 = AtomicU32::new(0);
7082        let id = NEXT.fetch_add(1, Ordering::Relaxed);
7083        let root =
7084            std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
7085        let origin = root.join("origin.git");
7086        let work = root.join("work");
7087        std::fs::create_dir_all(&origin).unwrap();
7088        std::fs::create_dir_all(&work).unwrap();
7089        test_git(&origin, &["init", "--bare", "-b", "main"]);
7090        test_git(&work, &["init", "-b", "main"]);
7091        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
7092        test_git(&work, &["config", "user.name", "spar test"]);
7093        test_git(&work, &["config", "commit.gpgsign", "false"]);
7094        test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
7095        test_git(&work, &["config", "filter.drop.smudge", "cat"]);
7096        std::fs::write(work.join("README.md"), "seed\n").unwrap();
7097        std::fs::write(work.join("data.txt"), "old\n").unwrap();
7098        std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
7099        std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
7100        test_git(&work, &["add", "."]);
7101        test_git(&work, &["commit", "-m", "seed"]);
7102        test_git(
7103            &work,
7104            &["remote", "add", "origin", origin.to_str().unwrap()],
7105        );
7106        test_git(&work, &["push", "-u", "origin", "main"]);
7107        test_git(
7108            &work,
7109            &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
7110        );
7111        let cfg = crate::config::parse(
7112            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
7113        )
7114        .unwrap();
7115        let repo = Repo::open(&work, &cfg).unwrap();
7116        let path = repo.worktree_for_pr_head(number).unwrap();
7117        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7118        (ReviewFixture { root }, repo, path, checkpoint)
7119    }
7120
7121    #[test]
7122    fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
7123        let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
7124
7125        repo.release_review_worktree_checked(901, &checkpoint)
7126            .unwrap();
7127
7128        assert!(!path.exists());
7129    }
7130
7131    #[test]
7132    fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
7133        let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
7134        let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
7135        std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
7136        test_git(&path, &["add", "recovery.txt"]);
7137        test_git(&path, &["commit", "-m", "recovery commit"]);
7138        let recovery = test_git(&path, &["rev-parse", "HEAD"]);
7139        test_git(&path, &["reset", "--hard", "main"]);
7140
7141        assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
7142        test_git(
7143            &path,
7144            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7145        );
7146    }
7147
7148    #[test]
7149    fn a_review_ref_reflog_only_commit_prevents_deletion() {
7150        let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
7151        let local_ref = review_ref(921);
7152        let original = test_git(&path, &["rev-parse", &local_ref]);
7153        let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
7154        let recovery = test_git(
7155            &path,
7156            &[
7157                "commit-tree",
7158                tree.trim(),
7159                "-p",
7160                original.trim(),
7161                "-m",
7162                "review ref recovery",
7163            ],
7164        );
7165        test_git(
7166            &path,
7167            &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
7168        );
7169        test_git(
7170            &path,
7171            &["update-ref", &local_ref, original.trim(), recovery.trim()],
7172        );
7173
7174        assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
7175        assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
7176        test_git(
7177            &path,
7178            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7179        );
7180    }
7181
7182    #[test]
7183    fn an_unpublished_commit_message_draft_is_recoverable() {
7184        let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
7185        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
7186        let git_dir = if raw.is_absolute() {
7187            raw
7188        } else {
7189            path.join(raw)
7190        };
7191        std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
7192
7193        assert!(repository_has_recoverable_work(&path, true).unwrap());
7194        assert_eq!(
7195            "unique recovery draft\n",
7196            std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
7197        );
7198    }
7199
7200    #[test]
7201    fn a_changed_review_worktree_is_retained_after_a_checked_read() {
7202        let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
7203        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
7204
7205        let error = repo
7206            .release_review_worktree_checked(902, &checkpoint)
7207            .unwrap_err();
7208
7209        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7210        assert!(error.to_string().contains("kept for recovery"), "{error}");
7211        assert_eq!(
7212            "recover me\n",
7213            std::fs::read_to_string(path.join("README.md")).unwrap()
7214        );
7215        repo.release_review_worktree(902);
7216    }
7217
7218    #[test]
7219    fn a_review_commit_is_retained_after_a_checked_read() {
7220        let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
7221        std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
7222        test_git(&path, &["add", "review-note.txt"]);
7223        test_git(&path, &["commit", "-m", "local review recovery"]);
7224        let head = test_git(&path, &["rev-parse", "HEAD"]);
7225
7226        let error = repo
7227            .release_review_worktree_checked(903, &checkpoint)
7228            .unwrap_err();
7229
7230        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7231        assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
7232        assert_eq!(
7233            "recover me\n",
7234            std::fs::read_to_string(path.join("review-note.txt")).unwrap()
7235        );
7236        repo.release_review_worktree(903);
7237    }
7238
7239    #[test]
7240    fn an_ignored_review_file_is_retained_after_a_checked_read() {
7241        let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
7242        std::fs::create_dir_all(path.join("generated")).unwrap();
7243        std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
7244
7245        let error = repo
7246            .release_review_worktree_checked(904, &checkpoint)
7247            .unwrap_err();
7248
7249        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7250        assert_eq!(
7251            "recover me\n",
7252            std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
7253        );
7254        repo.release_review_worktree(904);
7255    }
7256
7257    #[test]
7258    fn a_preexisting_ignored_review_file_change_is_retained() {
7259        let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
7260        std::fs::create_dir_all(path.join("generated")).unwrap();
7261        let ignored = path.join("generated/recovery.txt");
7262        std::fs::write(&ignored, "before\n").unwrap();
7263        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7264        std::fs::write(&ignored, "after!\n").unwrap();
7265
7266        let error = repo
7267            .release_review_worktree_checked(905, &checkpoint)
7268            .unwrap_err();
7269
7270        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7271        assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
7272        repo.release_review_worktree(905);
7273    }
7274
7275    #[test]
7276    fn a_preexisting_ignored_review_file_prevents_checked_removal() {
7277        let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
7278        std::fs::create_dir_all(path.join("generated")).unwrap();
7279        let ignored = path.join("generated/recovery.txt");
7280        std::fs::write(&ignored, "keep me\n").unwrap();
7281        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7282
7283        let error = repo
7284            .release_review_worktree_checked(906, &checkpoint)
7285            .unwrap_err();
7286
7287        assert!(error.to_string().contains("recoverable"), "{error}");
7288        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7289    }
7290
7291    #[test]
7292    fn overwriting_a_preexisting_untracked_file_is_detected() {
7293        let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
7294        let untracked = path.join("notes.txt");
7295        std::fs::write(&untracked, "before\n").unwrap();
7296        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7297        std::fs::write(&untracked, "after!\n").unwrap();
7298
7299        let error = repo
7300            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7301            .unwrap_err();
7302
7303        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7304        assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
7305    }
7306
7307    #[test]
7308    fn an_assume_unchanged_edit_is_detected() {
7309        let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
7310        test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
7311        std::fs::write(path.join("README.md"), "hidden\n").unwrap();
7312
7313        let error = repo
7314            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7315            .unwrap_err();
7316
7317        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7318        assert_eq!(
7319            "hidden\n",
7320            std::fs::read_to_string(path.join("README.md")).unwrap()
7321        );
7322    }
7323
7324    #[test]
7325    fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
7326        let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
7327        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7328        test_git(&path, &["add", "README.md"]);
7329        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7330
7331        let error = repo
7332            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7333            .unwrap_err();
7334
7335        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7336        assert_eq!(
7337            b"seed\r\n",
7338            std::fs::read(path.join("README.md")).unwrap().as_slice()
7339        );
7340    }
7341
7342    #[cfg(unix)]
7343    #[test]
7344    fn a_mode_edit_is_detected_when_filemode_is_disabled() {
7345        use std::os::unix::fs::PermissionsExt;
7346
7347        let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
7348        test_git(&path, &["config", "core.filemode", "false"]);
7349        let readme = path.join("README.md");
7350        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7351        permissions.set_mode(0o755);
7352        std::fs::set_permissions(&readme, permissions).unwrap();
7353        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7354
7355        let error = repo
7356            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7357            .unwrap_err();
7358
7359        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7360        assert_eq!(
7361            0o755,
7362            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7363        );
7364    }
7365
7366    #[test]
7367    fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
7368        let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
7369        std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
7370        test_git(&path, &["add", ".gitattributes"]);
7371        test_git(&path, &["commit", "-m", "select data filter"]);
7372        let baseline = repo.worktree_baseline(&path).unwrap();
7373        std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
7374
7375        assert!(repo
7376            .commit_pending_changes(&path, &baseline, "change data", "change data")
7377            .unwrap());
7378        let error = repo
7379            .refuse_unrepresented_tracked_changes(&path, &baseline)
7380            .unwrap_err();
7381
7382        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7383        assert_eq!(
7384            "secret: recover me\nnew\n",
7385            std::fs::read_to_string(path.join("data.txt")).unwrap()
7386        );
7387        assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
7388    }
7389
7390    #[test]
7391    fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
7392        let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
7393        std::fs::create_dir_all(path.join("target")).unwrap();
7394        let untracked = path.join("target/user.yaml");
7395        std::fs::write(&untracked, "user data\n").unwrap();
7396        let baseline = repo.worktree_baseline(&path).unwrap();
7397        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7398
7399        assert!(repo
7400            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7401            .unwrap());
7402
7403        assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
7404        assert_eq!(
7405            "?? target/user.yaml\n",
7406            test_git(&path, &["status", "--short", "--untracked-files=all"])
7407        );
7408        assert!(test_git(
7409            &path,
7410            &[
7411                "ls-tree",
7412                "-r",
7413                "--name-only",
7414                "HEAD",
7415                "--",
7416                "target/user.yaml"
7417            ]
7418        )
7419        .is_empty());
7420    }
7421
7422    #[test]
7423    fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
7424        let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
7425        std::fs::create_dir_all(path.join("target")).unwrap();
7426        let untracked = path.join("target/user.yaml");
7427        std::fs::write(&untracked, "before\n").unwrap();
7428        let baseline = repo.worktree_baseline(&path).unwrap();
7429        let before = test_git(&path, &["rev-parse", "HEAD"]);
7430        std::fs::write(&untracked, "after\n").unwrap();
7431        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7432
7433        let error = repo
7434            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7435            .unwrap_err();
7436
7437        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7438        assert!(error.to_string().contains("target/user.yaml"), "{error}");
7439        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7440        assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
7441        assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
7442    }
7443
7444    #[test]
7445    fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
7446        let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
7447        let baseline = repo.worktree_baseline(&path).unwrap();
7448        std::fs::create_dir_all(path.join("target")).unwrap();
7449        std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
7450
7451        assert!(repo
7452            .commit_pending_changes(&path, &baseline, "add file", "add file")
7453            .unwrap());
7454
7455        assert_eq!(
7456            "new file\n",
7457            test_git(&path, &["show", "HEAD:target/new.txt"])
7458        );
7459        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7460    }
7461
7462    #[test]
7463    fn deleting_existing_ignored_work_stops_a_managed_commit() {
7464        let (_fixture, repo, path, _checkpoint) = review_fixture("deleted-ignored", 912);
7465        std::fs::create_dir_all(path.join("generated")).unwrap();
7466        let ignored = path.join("generated/keep.txt");
7467        std::fs::write(&ignored, "user data\n").unwrap();
7468        let baseline = repo.worktree_baseline(&path).unwrap();
7469        let before = test_git(&path, &["rev-parse", "HEAD"]);
7470        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7471        std::fs::remove_file(&ignored).unwrap();
7472
7473        let error = repo
7474            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7475            .unwrap_err();
7476
7477        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7478        assert!(error.to_string().contains("existing untracked"), "{error}");
7479        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7480        assert_eq!(
7481            "tracked change\n",
7482            std::fs::read_to_string(path.join("README.md")).unwrap()
7483        );
7484    }
7485
7486    #[test]
7487    fn new_ignored_work_stops_a_managed_commit_with_tracked_changes() {
7488        let (_fixture, repo, path, _checkpoint) = review_fixture("mixed-ignored", 926);
7489        let baseline = repo.worktree_baseline(&path).unwrap();
7490        let before = test_git(&path, &["rev-parse", "HEAD"]);
7491        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7492        std::fs::create_dir_all(path.join("generated")).unwrap();
7493        let ignored = path.join("generated/recovery.txt");
7494        std::fs::write(&ignored, "keep me\n").unwrap();
7495
7496        let error = repo
7497            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7498            .unwrap_err();
7499
7500        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7501        assert!(error.to_string().contains("recovery.txt"), "{error}");
7502        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7503        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7504        assert!(test_git(&path, &["status", "--porcelain"])
7505            .lines()
7506            .any(|line| line == "M  README.md"));
7507    }
7508
7509    #[test]
7510    fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
7511        let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
7512        test_git(&path, &["config", "core.autocrlf", "true"]);
7513        std::fs::write(path.join("README.md"), "seed\n").unwrap();
7514        assert_eq!(
7515            test_git(&path, &["hash-object", "README.md"]).trim(),
7516            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
7517        );
7518
7519        assert!(repository_has_recoverable_work(&path, true).unwrap());
7520        assert_eq!(
7521            "seed\n",
7522            std::fs::read_to_string(path.join("README.md")).unwrap()
7523        );
7524    }
7525
7526    #[test]
7527    fn autocrlf_input_overrides_a_crlf_core_eol() {
7528        let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
7529        test_git(&path, &["config", "core.autocrlf", "input"]);
7530        test_git(&path, &["config", "core.eol", "crlf"]);
7531        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7532        assert_eq!(
7533            test_git(&path, &["hash-object", "README.md"]).trim(),
7534            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
7535        );
7536
7537        assert!(repository_has_recoverable_work(&path, true).unwrap());
7538        assert_eq!(
7539            b"seed\r\n",
7540            std::fs::read(path.join("README.md")).unwrap().as_slice()
7541        );
7542    }
7543
7544    #[cfg(unix)]
7545    #[test]
7546    fn a_non_executable_permission_change_is_recoverable() {
7547        use std::os::unix::fs::PermissionsExt;
7548
7549        let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
7550        let readme = path.join("README.md");
7551        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7552        permissions.set_mode(0o600);
7553        std::fs::set_permissions(&readme, permissions).unwrap();
7554        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7555
7556        let error = repo
7557            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7558            .unwrap_err();
7559
7560        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7561        assert!(repository_has_recoverable_work(&path, true).unwrap());
7562        assert_eq!(
7563            0o600,
7564            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7565        );
7566    }
7567
7568    #[cfg(unix)]
7569    #[test]
7570    fn a_managed_commit_skips_signing_and_hooks() {
7571        use std::os::unix::fs::PermissionsExt;
7572
7573        let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
7574        let common = common_git_dir(&path).unwrap();
7575        let hook = common.join("hooks/pre-commit");
7576        let marker = fixture.root.join("hook-ran");
7577        std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
7578        std::fs::write(
7579            &hook,
7580            format!(
7581                "#!/bin/sh\nprintf ran > {}\nexit 1\n",
7582                sh_quote(marker.to_str().unwrap())
7583            ),
7584        )
7585        .unwrap();
7586        let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
7587        permissions.set_mode(0o755);
7588        std::fs::set_permissions(&hook, permissions).unwrap();
7589        test_git(&path, &["config", "commit.gpgsign", "true"]);
7590        test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
7591        std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
7592        test_git(&path, &["add", "managed.txt"]);
7593
7594        repo.commit_staged_changes(&path, "record managed change")
7595            .unwrap();
7596
7597        assert!(!marker.exists());
7598        assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
7599    }
7600
7601    #[test]
7602    fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
7603        let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
7604        std::fs::write(
7605            path.join(".gitattributes"),
7606            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
7607        )
7608        .unwrap();
7609        test_git(&path, &["add", ".gitattributes"]);
7610        test_git(&path, &["commit", "-m", "select automatic text"]);
7611        test_git(&path, &["config", "core.autocrlf", "true"]);
7612
7613        assert!(repository_has_recoverable_work(&path, true).unwrap());
7614    }
7615
7616    #[test]
7617    fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
7618        let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
7619        std::fs::write(
7620            path.join(".gitattributes"),
7621            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
7622        )
7623        .unwrap();
7624        test_git(&path, &["add", ".gitattributes"]);
7625        test_git(&path, &["commit", "-m", "select ident expansion"]);
7626        std::fs::write(path.join("README.md"), "seed\n").unwrap();
7627
7628        assert!(repository_has_recoverable_work(&path, true).unwrap());
7629    }
7630
7631    /// Add ignore rules the way SPAR does, without a tracked change the
7632    /// worktree would then be kept for.
7633    fn exclude_paths(repo: &Repo, lines: &[&str]) {
7634        use std::io::Write;
7635        let path = repo.root().join(".git").join("info").join("exclude");
7636        let mut file = std::fs::OpenOptions::new()
7637            .create(true)
7638            .append(true)
7639            .open(&path)
7640            .unwrap();
7641        for line in lines {
7642            writeln!(file, "{line}").unwrap();
7643        }
7644    }
7645
7646    #[test]
7647    fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
7648        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
7649        exclude_paths(&repo, &["dist/"]);
7650        std::fs::create_dir_all(path.join("dist")).unwrap();
7651        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
7652        let baseline = repo.worktree_baseline(&path).unwrap();
7653        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
7654        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
7655
7656        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
7657        repo.refuse_changed_existing_untracked(&path, &baseline)
7658            .unwrap();
7659    }
7660
7661    #[test]
7662    fn a_new_ignored_file_outside_build_output_still_stops_a_call() {
7663        let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
7664        exclude_paths(&repo, &["dist/", ".env.local"]);
7665        std::fs::create_dir_all(path.join("dist")).unwrap();
7666        let baseline = repo.worktree_baseline(&path).unwrap();
7667        std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
7668        std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
7669
7670        let error = repo.refuse_new_ignored_files(&path, &baseline).unwrap_err();
7671
7672        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7673        assert!(error.to_string().contains(".env.local"), "{error}");
7674    }
7675
7676    #[test]
7677    fn a_read_only_inspection_may_rebuild_generated_output() {
7678        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
7679        exclude_paths(&repo, &["dist/"]);
7680        std::fs::create_dir_all(path.join("dist")).unwrap();
7681        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
7682        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7683        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
7684        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
7685
7686        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7687            .unwrap();
7688    }
7689
7690    #[test]
7691    fn a_read_only_inspection_may_not_change_an_ignored_file_elsewhere() {
7692        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
7693        exclude_paths(&repo, &["dist/", ".env.local"]);
7694        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
7695        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7696        std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
7697
7698        let error = repo
7699            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7700            .unwrap_err();
7701
7702        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7703    }
7704
7705    #[test]
7706    fn build_output_alone_does_not_keep_a_worktree() {
7707        let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
7708        exclude_paths(&repo, &["target/", "dist/"]);
7709        std::fs::create_dir_all(path.join("target/debug")).unwrap();
7710        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
7711        std::fs::create_dir_all(path.join("dist/cli")).unwrap();
7712        std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
7713
7714        assert!(!repository_has_recoverable_work(&path, true).unwrap());
7715        repo.release_review_worktree(933);
7716
7717        assert!(!path.exists());
7718    }
7719
7720    #[test]
7721    fn an_ignored_file_outside_build_output_keeps_a_worktree() {
7722        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
7723        exclude_paths(&repo, &["target/", ".env.local"]);
7724        std::fs::create_dir_all(path.join("target/debug")).unwrap();
7725        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
7726        std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
7727
7728        assert!(repository_has_recoverable_work(&path, true).unwrap());
7729        repo.release_review_worktree(934);
7730
7731        assert_eq!(
7732            "TOKEN=keep me\n",
7733            std::fs::read_to_string(path.join(".env.local")).unwrap()
7734        );
7735    }
7736
7737    #[test]
7738    fn a_repository_nested_in_build_output_keeps_a_worktree() {
7739        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
7740        exclude_paths(&repo, &["node_modules/"]);
7741        let nested = path.join("node_modules/local-dep");
7742        std::fs::create_dir_all(&nested).unwrap();
7743        test_git(&nested, &["init"]);
7744        std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
7745
7746        assert!(repository_has_recoverable_work(&path, true).unwrap());
7747        repo.release_review_worktree(935);
7748
7749        assert!(nested.join(".git").exists());
7750    }
7751
7752    #[test]
7753    fn an_ordinary_untracked_file_keeps_a_worktree() {
7754        let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
7755        std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
7756
7757        assert!(repository_has_recoverable_work(&path, true).unwrap());
7758        repo.release_review_worktree(936);
7759
7760        assert_eq!(
7761            "somebody's notes\n",
7762            std::fs::read_to_string(path.join("notes.md")).unwrap()
7763        );
7764    }
7765
7766    #[test]
7767    fn a_legacy_crlf_checkout_is_retained_conservatively() {
7768        let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
7769        std::fs::write(
7770            path.join(".gitattributes"),
7771            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
7772        )
7773        .unwrap();
7774        test_git(&path, &["add", ".gitattributes"]);
7775        test_git(&path, &["commit", "-m", "select legacy line endings"]);
7776
7777        assert!(repository_has_recoverable_work(&path, true).unwrap());
7778    }
7779
7780    #[test]
7781    fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
7782        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
7783        let nested = path.join("tracked");
7784        std::fs::create_dir_all(&nested).unwrap();
7785        std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
7786        test_git(&path, &["add", "tracked/seed.txt"]);
7787        test_git(&path, &["commit", "-m", "add tracked directory"]);
7788        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7789        test_git(&nested, &["init"]);
7790
7791        let error = repo
7792            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7793            .unwrap_err();
7794
7795        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7796        assert!(error.to_string().contains("Git entry"), "{error}");
7797        assert!(nested.join(".git").exists());
7798    }
7799
7800    #[test]
7801    fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
7802        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
7803
7804        let state = ignored_untracked_state(repo.root()).unwrap();
7805
7806        let relative = path.strip_prefix(repo.root()).unwrap();
7807        assert!(
7808            state.files.contains_key(relative),
7809            "{:?}",
7810            state.files.keys().collect::<Vec<_>>()
7811        );
7812        assert!(state.is_ignored(relative));
7813    }
7814
7815    #[test]
7816    fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
7817        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
7818        let baseline = repo.worktree_baseline(repo.root()).unwrap();
7819        std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
7820        std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
7821
7822        repo.refuse_new_ignored_files(repo.root(), &baseline)
7823            .unwrap();
7824        repo.refuse_changed_existing_untracked(repo.root(), &baseline)
7825            .unwrap();
7826    }
7827
7828    #[test]
7829    fn deleting_a_resident_worktree_during_a_call_is_refused() {
7830        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
7831        let baseline = repo.worktree_baseline(repo.root()).unwrap();
7832        std::fs::remove_dir_all(&path).unwrap();
7833
7834        let error = repo
7835            .refuse_new_ignored_files(repo.root(), &baseline)
7836            .unwrap_err();
7837
7838        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7839        assert!(error.to_string().contains("review-932"), "{error}");
7840    }
7841
7842    #[test]
7843    fn a_nested_repository_record_is_read_as_a_plain_path() {
7844        let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
7845        assert_eq!(Path::new("vendor/checkout"), path);
7846        assert!(nested);
7847
7848        let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
7849        assert_eq!(Path::new("vendor/notes.txt"), path);
7850        assert!(!nested);
7851
7852        assert!(untracked_record(b"/", "untracked").is_err());
7853    }
7854
7855    #[cfg(unix)]
7856    #[test]
7857    fn a_non_utf8_git_path_is_preserved_without_loss() {
7858        use std::os::unix::ffi::OsStrExt;
7859
7860        let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
7861
7862        assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
7863    }
7864
7865    #[test]
7866    fn guarded_merge_pins_the_reviewed_head() {
7867        let args = merge_pr_args("36", Some("abc123"), true);
7868        assert_eq!(
7869            vec![
7870                "pr",
7871                "merge",
7872                "36",
7873                "--squash",
7874                "--delete-branch",
7875                "--match-head-commit",
7876                "abc123"
7877            ],
7878            args
7879        );
7880    }
7881
7882    #[test]
7883    fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
7884        let pr = PrRef {
7885            number: 7,
7886            url: "https://example.test/pull/7".into(),
7887            title: "part one".into(),
7888        };
7889        let result = reconcile_pr_creation(
7890            "split-34-1",
7891            Err(crate::error::SparError::new("connection lost")),
7892            Ok(Some(pr)),
7893        )
7894        .unwrap();
7895        assert_eq!(7, result.number);
7896    }
7897
7898    #[test]
7899    fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
7900        let error = reconcile_pr_creation(
7901            "split-34-1",
7902            Err(crate::error::SparError::new("permission denied")),
7903            Ok(None),
7904        )
7905        .unwrap_err();
7906        assert!(error.to_string().contains("permission denied"), "{error}");
7907    }
7908
7909    #[test]
7910    fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
7911        let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
7912        assert!(pr_for_base(text, "split-34-2", "split-34-1")
7913            .unwrap()
7914            .is_none());
7915        let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
7916        assert_eq!(7, found.number);
7917    }
7918
7919    #[test]
7920    fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
7921        let result = reconcile_comment_post(
7922            34,
7923            "the summary",
7924            crate::error::SparError::new("connection lost"),
7925            Ok(vec![serde_json::json!({"body": "the summary"})]),
7926        );
7927        assert!(result.is_ok(), "{result:?}");
7928    }
7929
7930    #[test]
7931    fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
7932        let error = reconcile_comment_post(
7933            34,
7934            "the summary",
7935            crate::error::SparError::new("connection lost"),
7936            Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
7937        )
7938        .unwrap_err();
7939        assert_eq!("connection lost", error.to_string());
7940    }
7941
7942    #[test]
7943    fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
7944        let error = reconcile_comment_post(
7945            34,
7946            "the summary",
7947            crate::error::SparError::new("connection lost"),
7948            Err(crate::error::SparError::new("comments unavailable")),
7949        )
7950        .unwrap_err();
7951        assert!(
7952            error.to_string().contains("could not be verified"),
7953            "{error}"
7954        );
7955        assert!(
7956            error.to_string().contains("comments unavailable"),
7957            "{error}"
7958        );
7959        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7960        assert!(!error.worth_retrying());
7961    }
7962
7963    #[test]
7964    fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
7965        let result = reconcile_issue_edit(
7966            34,
7967            "wanted body",
7968            crate::error::SparError::new("connection lost"),
7969            Ok("wanted body".to_string()),
7970        );
7971        assert!(result.is_ok(), "{result:?}");
7972    }
7973
7974    #[test]
7975    fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
7976        let error = reconcile_issue_edit(
7977            34,
7978            "wanted body",
7979            crate::error::SparError::new("connection lost"),
7980            Err(crate::error::SparError::new("issue unavailable")),
7981        )
7982        .unwrap_err();
7983        assert!(
7984            error.to_string().contains("could not be verified"),
7985            "{error}"
7986        );
7987        assert!(error.to_string().contains("issue unavailable"), "{error}");
7988        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7989        assert!(!error.worth_retrying());
7990    }
7991
7992    #[test]
7993    fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
7994        let found = ExistingIssue {
7995            number: 101,
7996            url: "https://example.test/issues/101".into(),
7997            title: "child".into(),
7998            body: "body".into(),
7999            open: true,
8000        };
8001        let url = reconcile_issue_creation(
8002            "child",
8003            Err(crate::error::SparError::new("connection lost")),
8004            Ok(Some(found)),
8005        )
8006        .unwrap();
8007        assert_eq!("https://example.test/issues/101", url);
8008    }
8009
8010    #[test]
8011    fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
8012        let error = reconcile_issue_creation(
8013            "child",
8014            Err(crate::error::SparError::new("permission denied")),
8015            Ok(None),
8016        )
8017        .unwrap_err();
8018        assert!(error.to_string().contains("permission denied"), "{error}");
8019    }
8020
8021    #[test]
8022    fn an_unverifiable_issue_creation_is_marked_uncertain() {
8023        let error = reconcile_issue_creation(
8024            "child",
8025            Err(crate::error::SparError::new("connection lost")),
8026            Err(crate::error::SparError::new("issues unavailable")),
8027        )
8028        .unwrap_err();
8029        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8030        assert!(!error.worth_retrying());
8031    }
8032
8033    #[test]
8034    fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
8035        let result = reconcile_failed_split_push(
8036            "split-34-1",
8037            crate::error::SparError::new("connection lost"),
8038            Ok("abc123\n".into()),
8039            Ok("abc123\trefs/heads/split-34-1\n".into()),
8040        );
8041        assert!(result.is_ok(), "{result:?}");
8042    }
8043
8044    #[test]
8045    fn a_split_push_collision_is_definite_and_never_overwrites() {
8046        let error = reconcile_failed_split_push(
8047            "split-34-1",
8048            crate::error::SparError::new("lease rejected"),
8049            Ok("abc123\n".into()),
8050            Ok("def456\trefs/heads/split-34-1\n".into()),
8051        )
8052        .unwrap_err();
8053        assert!(!error.retain_worktree());
8054        assert!(
8055            error.to_string().contains("Nothing was overwritten"),
8056            "{error}"
8057        );
8058    }
8059
8060    #[test]
8061    fn an_unreadable_split_push_result_keeps_the_worktree() {
8062        let error = reconcile_failed_split_push(
8063            "split-34-1",
8064            crate::error::SparError::new("connection lost"),
8065            Ok("abc123\n".into()),
8066            Err(crate::error::SparError::new("origin unavailable")),
8067        )
8068        .unwrap_err();
8069        assert!(error.retain_worktree());
8070        assert!(error.to_string().contains("could not confirm"), "{error}");
8071    }
8072
8073    /// Follow-up deduplication compares a title it computed against the title
8074    /// GitHub stored. If those two transforms can disagree, the check never
8075    /// matches and every review round files another copy of the same issue.
8076    #[test]
8077    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
8078        let repo = repo_for_titles();
8079        for raw in [
8080            "Retry loop spins \u{2014} Retry-After parses to zero",
8081            "plain title",
8082            "  spread   over\nlines  ",
8083            "\u{1F916} Generated with something",
8084            &format!("a \u{2014} {}", "very long title ".repeat(20)),
8085            &"x".repeat(300),
8086            &format!("{} \u{2014} end", "y".repeat(88)),
8087            // Exactly the budget, with two spaceless dashes. The scrub turns
8088            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
8089            // character over budget per dash and a second pass clips again,
8090            // producing a different string. Scrub-then-clip cannot.
8091            &{
8092                let tail = "a\u{2014}b c\u{2014}d";
8093                let pad = Style::default().max_title_chars - tail.chars().count();
8094                format!("{}{tail}", "w".repeat(pad))
8095            },
8096        ] {
8097            let once = repo.clean_title(raw).unwrap();
8098            let twice = repo.clean_title(&once).unwrap();
8099            assert_eq!(once, twice, "not idempotent for {raw:?}");
8100            assert!(
8101                once.chars().count() <= repo.style.max_title_chars,
8102                "over budget: {once:?}"
8103            );
8104            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
8105        }
8106    }
8107
8108    #[test]
8109    fn a_title_with_an_em_dash_survives_as_readable_text() {
8110        let repo = repo_for_titles();
8111        assert_eq!(
8112            "Retry loop spins, Retry-After parses to zero",
8113            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
8114                .unwrap()
8115        );
8116    }
8117
8118    #[test]
8119    fn sh_quote_survives_a_quote() {
8120        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
8121    }
8122
8123    #[test]
8124    fn sh_quote_wraps_a_space() {
8125        assert_eq!(
8126            "'/Applications/My App/spar'",
8127            sh_quote("/Applications/My App/spar")
8128        );
8129    }
8130
8131    #[test]
8132    fn finished_states_are_recognised_case_insensitively() {
8133        assert!(is_finished("MERGED"));
8134        assert!(is_finished("closed"));
8135        assert!(!is_finished("OPEN"));
8136        assert!(!is_finished(""));
8137    }
8138
8139    fn state() -> PersistedState {
8140        PersistedState {
8141            version: 1,
8142            checkpoint: 0,
8143            round: 4,
8144            next_actor: "codex".into(),
8145            status: Status::Pending,
8146            pr_head: "abc123".into(),
8147            ledger: Ledger::new(),
8148            filed: vec![],
8149            open_findings: vec![Finding {
8150                severity: Severity::Blocking,
8151                title: "Unchecked error".into(),
8152                detail: "the failure is discarded".into(),
8153                file: "src/a.rs:12".into(),
8154                ..Finding::default()
8155            }],
8156            disputes: vec![Dispute {
8157                title: "Retry limit".into(),
8158                file: "src/net.rs".into(),
8159                reasoning: "the caller already bounds it".into(),
8160            }],
8161            noted: vec![Finding {
8162                severity: Severity::NonBlocking,
8163                title: "Timeout is fixed".into(),
8164                file: "src/config.rs".into(),
8165                ..Finding::default()
8166            }],
8167        }
8168    }
8169
8170    #[test]
8171    fn a_state_comment_round_trips() {
8172        let body = format!(
8173            "{STATE_MARKER}\n{}\n-->",
8174            serde_json::to_string(&state()).unwrap()
8175        );
8176        let back = parse_state_comment(&body).unwrap();
8177        assert_eq!(4, back.round);
8178        assert_eq!("codex", back.next_actor);
8179        assert_eq!("abc123", back.pr_head);
8180        assert_eq!("Unchecked error", back.open_findings[0].title);
8181        assert_eq!("src/net.rs", back.disputes[0].file);
8182        assert_eq!("Timeout is fixed", back.noted[0].title);
8183    }
8184
8185    #[test]
8186    fn old_state_without_new_lists_still_parses() {
8187        let body = format!(
8188            "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
8189             \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
8190        );
8191        let back = parse_state_comment(&body).expect("old state");
8192        assert!(back.open_findings.is_empty());
8193        assert!(back.disputes.is_empty());
8194        assert!(back.noted.is_empty());
8195        assert!(back.pr_head.is_empty());
8196        assert_eq!(0, back.checkpoint);
8197    }
8198
8199    #[test]
8200    fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
8201        let mut local = state();
8202        local.pr_head = "old".into();
8203        local.round = 9;
8204        let mut remote = state();
8205        remote.pr_head = "current".into();
8206        remote.round = 4;
8207
8208        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
8209        assert_eq!("current", chosen.pr_head);
8210        assert_eq!(4, chosen.round);
8211    }
8212
8213    #[test]
8214    fn checkpoint_order_breaks_same_round_ties() {
8215        let mut local = state();
8216        local.pr_head = "current".into();
8217        local.round = 4;
8218        local.checkpoint = 8;
8219        let mut remote = local.clone();
8220        remote.checkpoint = 7;
8221        remote.open_findings.clear();
8222
8223        let chosen = choose_state_for_head(vec![local], "current").unwrap();
8224        assert_eq!(8, chosen.checkpoint);
8225
8226        let mut local = state();
8227        local.pr_head = "current".into();
8228        local.round = 4;
8229        local.checkpoint = 8;
8230        let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
8231        assert_eq!(8, chosen.checkpoint);
8232    }
8233
8234    #[test]
8235    fn legacy_same_round_tie_keeps_the_local_checkpoint() {
8236        let mut local = state();
8237        local.pr_head = "current".into();
8238        local.round = 4;
8239        local.open_findings.push(Finding {
8240            title: "local checkpoint".into(),
8241            ..Finding::default()
8242        });
8243        let mut remote = state();
8244        remote.pr_head = "current".into();
8245        remote.round = 4;
8246
8247        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
8248        assert_eq!(
8249            "local checkpoint",
8250            chosen.open_findings.last().unwrap().title
8251        );
8252    }
8253
8254    /// It must render as nothing, so PRs are not littered with machine state.
8255    #[test]
8256    fn the_state_block_is_an_html_comment() {
8257        let body = format!(
8258            "{STATE_MARKER}\n{}\n-->",
8259            serde_json::to_string(&state()).unwrap()
8260        );
8261        assert!(body.starts_with("<!--"));
8262        assert!(body.trim_end().ends_with("-->"));
8263        assert!(!body[..body.find('{').unwrap()].contains("-->"));
8264    }
8265
8266    #[test]
8267    fn an_unrelated_json_block_is_not_state() {
8268        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
8269    }
8270
8271    /// Patching and deleting a comment is not something to do on a substring.
8272    ///
8273    /// A maintainer who notices the block and asks about it writes a comment
8274    /// containing `spar:state`, and the loose filter could not tell theirs from
8275    /// spar's. The next `persist` replaced their words with JSON and
8276    /// `spar clean --pr-state` later deleted it.
8277    #[test]
8278    fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
8279        let block = format!(
8280            "{STATE_MARKER}\n{}\n-->",
8281            serde_json::to_string(&state()).unwrap()
8282        );
8283        assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
8284        assert!(
8285            is_state_comment(&block, "Spar-Bot", "spar-bot"),
8286            "a login differs only in case"
8287        );
8288
8289        assert!(
8290            !is_state_comment(
8291                "why is there a `<!-- spar:state` block on this PR?",
8292                "maintainer",
8293                "spar-bot"
8294            ),
8295            "a question about the block was treated as the block"
8296        );
8297        assert!(
8298            !is_state_comment(&block, "maintainer", "spar-bot"),
8299            "somebody else's comment is not spar's to rewrite"
8300        );
8301        assert!(
8302            !is_state_comment(
8303                &format!("here is what spar writes:\n\n{block}"),
8304                "spar-bot",
8305                "spar-bot"
8306            ),
8307            "the marker has to open the body, not appear in it"
8308        );
8309        assert!(
8310            !is_state_comment(
8311                &format!("{STATE_MARKER}\n{{not json\n-->"),
8312                "spar-bot",
8313                "spar-bot"
8314            ),
8315            "a block that does not parse is not state"
8316        );
8317        assert!(
8318            !is_state_comment(&block, "spar-bot", ""),
8319            "an unknown viewer proves nothing about who wrote it"
8320        );
8321    }
8322
8323    #[test]
8324    fn a_malformed_state_comment_is_none_not_a_panic() {
8325        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
8326    }
8327
8328    #[test]
8329    fn atomic_write_leaves_no_temp_file() {
8330        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
8331        let _ = std::fs::remove_dir_all(&dir);
8332        let path = dir.join("state").join("pr-7.json");
8333        write_json_atomic(&path, &state()).unwrap();
8334        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
8335            .unwrap()
8336            .flatten()
8337            .filter_map(|e| e.file_name().to_str().map(str::to_string))
8338            .collect();
8339        assert_eq!(vec!["pr-7.json".to_string()], files);
8340        let _ = std::fs::remove_dir_all(&dir);
8341    }
8342
8343    #[test]
8344    fn atomic_write_overwrites_rather_than_accumulating() {
8345        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
8346        let _ = std::fs::remove_dir_all(&dir);
8347        let path = dir.join("pr-7.json");
8348        for round in 1..4 {
8349            let mut s = state();
8350            s.round = round;
8351            write_json_atomic(&path, &s).unwrap();
8352        }
8353        let back: PersistedState =
8354            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
8355        assert_eq!(3, back.round);
8356        let _ = std::fs::remove_dir_all(&dir);
8357    }
8358
8359    #[test]
8360    fn style_from_env_defaults_to_enforcing() {
8361        std::env::remove_var("SPAR_BAN_EM_DASH");
8362        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
8363        let style = style_from_env();
8364        assert!(style.ban_em_dash && style.ban_ai_attribution);
8365        assert!(
8366            !style.terse,
8367            "the commit filter must not truncate a commit message"
8368        );
8369    }
8370}
8371
8372#[cfg(test)]
8373mod comment_page_tests {
8374    use super::*;
8375
8376    #[test]
8377    fn a_single_merged_array_is_read() {
8378        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
8379        assert_eq!(2, pages.len());
8380        assert_eq!(Some(2), pages[1]["id"].as_i64());
8381    }
8382
8383    #[test]
8384    fn concatenated_pages_from_an_older_gh_are_read_too() {
8385        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
8386        assert_eq!(2, pages.len());
8387    }
8388
8389    /// A comment body containing a bracket pair used to split the payload into
8390    /// two invalid halves, so no state comment was found and a resume silently
8391    /// started from round one.
8392    #[test]
8393    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
8394        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
8395        let pages = parse_comment_pages(text);
8396        assert_eq!(2, pages.len(), "{pages:?}");
8397        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
8398    }
8399
8400    #[test]
8401    fn empty_output_is_no_comments_not_a_panic() {
8402        assert!(parse_comment_pages("").is_empty());
8403        assert!(parse_comment_pages("   ").is_empty());
8404        assert!(parse_comment_pages("[]").is_empty());
8405    }
8406
8407    #[test]
8408    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
8409        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
8410    }
8411
8412    #[test]
8413    fn a_write_postcheck_rejects_truncated_comment_pages() {
8414        let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
8415        assert!(
8416            error.to_string().contains("unexpected comment pages"),
8417            "{error}"
8418        );
8419    }
8420
8421    #[test]
8422    fn a_write_postcheck_rejects_empty_or_non_array_output() {
8423        assert!(try_parse_comment_pages("").is_err());
8424        assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
8425        assert!(try_parse_comment_pages("[]").is_ok());
8426    }
8427
8428    #[test]
8429    fn state_is_found_in_the_last_matching_comment() {
8430        let payload = |round: u32| {
8431            format!(
8432                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
8433            )
8434        };
8435        let text = serde_json::to_string(&serde_json::json!([
8436            {"id": 1, "body": payload(1)},
8437            {"id": 2, "body": "looks good to me"},
8438            {"id": 3, "body": payload(5)},
8439        ]))
8440        .unwrap();
8441        let pages = parse_comment_pages(&text);
8442        let last = pages
8443            .iter()
8444            .rev()
8445            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
8446            .unwrap();
8447        assert_eq!(5, last.round);
8448    }
8449}
8450
8451#[cfg(test)]
8452mod linked_pr_tests {
8453    use super::*;
8454
8455    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
8456    /// It carries an id and a whole repository object per linked issue, and a
8457    /// parser that chokes on those reports "no pull request", which is the one
8458    /// answer that makes spar implement over the top of somebody's work.
8459    const REAL_PAYLOAD: &str = r#"[
8460      {"number":14252,"title":"fix: reject leading-dash branch names",
8461       "url":"https://github.com/cli/cli/pull/14252",
8462       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
8463         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
8464         "url":"https://github.com/cli/cli/issues/14238"}]},
8465      {"number":14217,"title":"another change",
8466       "url":"https://github.com/cli/cli/pull/14217",
8467       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
8468         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
8469         "url":"https://github.com/cli/cli/issues/9761"}]},
8470      {"number":14200,"title":"unlinked work",
8471       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
8472    ]"#;
8473
8474    #[test]
8475    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
8476        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
8477        assert_eq!(14252, pr.number);
8478        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
8479    }
8480
8481    #[test]
8482    fn the_right_pr_is_picked_out_of_several() {
8483        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
8484    }
8485
8486    #[test]
8487    fn an_issue_nobody_is_working_on_finds_nothing() {
8488        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
8489    }
8490
8491    #[test]
8492    fn an_unlinked_pr_is_never_matched() {
8493        // 14200 closes nothing, so no issue number should ever return it.
8494        for issue in [14200, 0, 1] {
8495            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
8496                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
8497            }
8498        }
8499    }
8500
8501    #[test]
8502    fn empty_or_broken_output_is_none_rather_than_a_panic() {
8503        assert!(find_linked_pr("", 1).is_none());
8504        assert!(find_linked_pr("[]", 1).is_none());
8505        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
8506        assert!(find_linked_pr("[{\"number\":", 1).is_none());
8507    }
8508
8509    /// "gh could not answer" and "nobody is working on it" are different
8510    /// answers, and only one of them means implement from scratch.
8511    ///
8512    /// The parse used to fold both into `None`, so an authentication failure or
8513    /// a rate limit read as an empty list and spar opened a second pull request
8514    /// for an issue somebody already had work open on.
8515    #[test]
8516    fn an_unreadable_list_is_not_an_empty_list() {
8517        assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
8518        assert!(linked_pr_rows("[{\"number\":").is_none());
8519        assert!(linked_pr_rows("").is_none());
8520
8521        let empty = linked_pr_rows("[]").expect("an empty list is an answer");
8522        assert!(empty.is_empty());
8523        assert!(pick_linked_pr(&empty, 14238).is_none());
8524
8525        let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
8526        assert_eq!(3, listed.len());
8527        assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
8528    }
8529
8530    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
8531    #[test]
8532    fn pr_view_reads_the_cross_repository_flag() {
8533        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
8534                       "baseRefName":"main","state":"OPEN",
8535                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
8536        let pr: PrView = serde_json::from_str(json).unwrap();
8537        assert!(pr.is_cross_repository);
8538        assert!(pr.is_open());
8539
8540        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
8541        assert!(
8542            !serde_json::from_str::<PrView>(&same_repo)
8543                .unwrap()
8544                .is_cross_repository
8545        );
8546    }
8547}
8548
8549#[cfg(test)]
8550mod min_number_tests {
8551    /// The floor is applied before the cap, which is the order that matters.
8552    /// spar takes the *lowest* numbered open items, so a repository with a tail
8553    /// of old issues would otherwise spend its whole run in the tail: the cap
8554    /// would be filled by the oldest items and the floor would never be
8555    /// reached. Filtering first is what makes the setting do anything.
8556    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
8557        let mut numbers: Vec<i64> = open.to_vec();
8558        numbers.sort_unstable();
8559        if min_number > 0 {
8560            numbers.retain(|n| *n >= min_number);
8561        }
8562        numbers.truncate(limit);
8563        numbers
8564    }
8565
8566    #[test]
8567    fn the_floor_is_applied_before_the_cap_not_after() {
8568        let open = [12, 13, 14, 480, 481, 482];
8569        assert_eq!(vec![480, 481], pick(&open, 2, 480));
8570        // Capping first would have returned the two oldest and then filtered
8571        // them all away, leaving nothing.
8572        assert!(!pick(&open, 2, 480).is_empty());
8573    }
8574
8575    #[test]
8576    fn no_floor_keeps_the_old_behaviour() {
8577        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
8578    }
8579
8580    #[test]
8581    fn the_floor_is_inclusive() {
8582        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
8583    }
8584
8585    #[test]
8586    fn a_floor_above_everything_open_yields_nothing() {
8587        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
8588    }
8589}