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
5894/// A Git entry the outer repository cannot account for.
5895///
5896/// A checkout Git itself reports as a nested repository is expected. Its
5897/// directory is recorded by identity in the untracked snapshot and keeps the
5898/// worktree from being released, so a reviewer that clones a reproduction into
5899/// ignored build output neither loses that clone nor fails the run over it.
5900/// The scan does not descend into one, because what is under it belongs to
5901/// that repository.
5902///
5903/// A Git entry Git does not report is the one worth stopping for. Running
5904/// `git init` inside a directory that already holds tracked files leaves
5905/// nothing in any listing while making every file written under it invisible
5906/// to the outer worktree, so SPAR can no longer prove what is there.
5907fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
5908    let root = std::fs::canonicalize(cwd)
5909        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5910    let mut allowed = BTreeSet::from([root.join(".git")]);
5911    let mut checkouts = BTreeSet::new();
5912    let mut repositories = vec![root.clone()];
5913    let mut visited = BTreeSet::new();
5914    while let Some(repository) = repositories.pop() {
5915        let canonical = std::fs::canonicalize(&repository)
5916            .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5917        if !visited.insert(canonical.clone()) {
5918            bail!("submodule recursion revisited {}", canonical.display());
5919        }
5920        for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
5921            let (path, nested) = untracked_record(&raw, "untracked")?;
5922            if nested {
5923                checkouts.insert(canonical.join(path));
5924            }
5925        }
5926        for link in gitlinks(&canonical)? {
5927            let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
5928                continue;
5929            };
5930            allowed.insert(submodule.join(".git"));
5931            repositories.push(submodule);
5932        }
5933    }
5934
5935    let scan_root = root.clone();
5936    let mut directories = vec![root];
5937    while let Some(directory) = directories.pop() {
5938        let entries = std::fs::read_dir(&directory)
5939            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5940        for entry in entries {
5941            let entry =
5942                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
5943            let path = entry.path();
5944            if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
5945                continue;
5946            }
5947            if entry.file_name() == OsStr::new(".git") {
5948                if !allowed.contains(&path) {
5949                    return Ok(Some(path));
5950                }
5951                continue;
5952            }
5953            let kind = entry
5954                .file_type()
5955                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
5956            if kind.is_dir() && !checkouts.contains(&path) {
5957                directories.push(path);
5958            }
5959        }
5960    }
5961    Ok(None)
5962}
5963
5964pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
5965    if let Some(path) = unexpected_nested_git_entry(cwd)? {
5966        bail!(
5967            "the worktree contains a Git entry at {} that Git does not report as a nested \
5968             checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
5969             prove they are unchanged.",
5970            path.display()
5971        );
5972    }
5973    let root = std::fs::canonicalize(cwd)
5974        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5975    let mut repositories = BTreeMap::new();
5976    let mut visited = BTreeSet::new();
5977    collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
5978    Ok(GitState { repositories })
5979}
5980
5981fn collect_git_state(
5982    repository: &Path,
5983    prefix: &Path,
5984    visited: &mut BTreeSet<PathBuf>,
5985    repositories: &mut BTreeMap<PathBuf, RepositoryState>,
5986) -> Result<()> {
5987    let canonical = std::fs::canonicalize(repository)
5988        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5989    if !visited.insert(canonical.clone()) {
5990        bail!("submodule recursion revisited {}", canonical.display());
5991    }
5992    let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
5993    let head = head.trim().to_string();
5994    if head.is_empty() {
5995        bail!("git returned an empty head for {}", repository.display());
5996    }
5997    let unsafe_index_flags = unsafe_index_flags(repository)?;
5998    let tracked = tracked_entries(repository)?;
5999    let gitlinks = gitlinks(repository)?;
6000    if repositories
6001        .insert(
6002            prefix.to_path_buf(),
6003            RepositoryState {
6004                head,
6005                unsafe_index_flags,
6006                tracked,
6007                gitlinks: gitlinks
6008                    .iter()
6009                    .map(|link| (link.path.clone(), link.oid.clone()))
6010                    .collect(),
6011            },
6012        )
6013        .is_some()
6014    {
6015        bail!("Git state contains duplicate repository path {:?}", prefix);
6016    }
6017
6018    for link in gitlinks {
6019        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6020            continue;
6021        };
6022        collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
6023    }
6024    Ok(())
6025}
6026
6027fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
6028    let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
6029    if !listed.is_empty() && !listed.ends_with(&[0]) {
6030        bail!(
6031            "git returned an unterminated index-flag listing for {}",
6032            cwd.display()
6033        );
6034    }
6035    let mut unsafe_records = Vec::new();
6036    for record in listed
6037        .split(|byte| *byte == 0)
6038        .filter(|record| !record.is_empty())
6039    {
6040        if record.len() < 3 || record[1] != b' ' {
6041            bail!(
6042                "git returned a malformed index-flag record for {}",
6043                cwd.display()
6044            );
6045        }
6046        if record[0] != b'H' {
6047            unsafe_records.extend_from_slice(record);
6048            unsafe_records.push(0);
6049        }
6050    }
6051    Ok(unsafe_records)
6052}
6053
6054pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
6055    safe_git_state(cwd).map(|_| ())
6056}
6057
6058pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
6059    let state = git_state(cwd)?;
6060    if let Some((path, _repository)) = state
6061        .repositories
6062        .iter()
6063        .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
6064    {
6065        let label = if path.as_os_str().is_empty() {
6066            cwd.to_path_buf()
6067        } else {
6068            cwd.join(path)
6069        };
6070        bail!(
6071            "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
6072             SPAR cannot prove the working files are unchanged, so it was kept.",
6073            label.display()
6074        );
6075    }
6076    Ok(state)
6077}
6078
6079fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
6080    if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
6081        return Ok(true);
6082    }
6083    let mut visited = BTreeSet::new();
6084    repository_has_recoverable_work_inner(cwd, include_ignored, &mut visited)
6085}
6086
6087fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
6088    let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
6089    let git_dir = PathBuf::from(git_dir.trim());
6090    let git_dir = if git_dir.is_absolute() {
6091        git_dir
6092    } else {
6093        cwd.join(git_dir)
6094    };
6095    let git_dir = std::fs::canonicalize(&git_dir)
6096        .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
6097    match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
6098        Ok(_) => return Ok(true),
6099        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6100        Err(error) => {
6101            return Err(spar_err!(
6102                "could not inspect per-worktree configuration in {}: {error}",
6103                git_dir.display()
6104            ))
6105        }
6106    }
6107
6108    let orig_head = git_dir.join("ORIG_HEAD");
6109    match std::fs::symlink_metadata(&orig_head) {
6110        Ok(metadata) if metadata.is_file() => {
6111            let oid = std::fs::read_to_string(&orig_head)
6112                .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
6113            let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
6114                return Ok(true);
6115            };
6116            if !commit_has_shared_ref(cwd, &commit)? {
6117                return Ok(true);
6118            }
6119        }
6120        Ok(_) => return Ok(true),
6121        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6122        Err(error) => {
6123            return Err(spar_err!(
6124                "could not inspect {}: {error}",
6125                orig_head.display()
6126            ))
6127        }
6128    }
6129
6130    let edit_message = git_dir.join("COMMIT_EDITMSG");
6131    match std::fs::symlink_metadata(&edit_message) {
6132        Ok(metadata) if metadata.is_file() => {
6133            let draft = std::fs::read(&edit_message)
6134                .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
6135            if draft != head_commit_message(cwd)? {
6136                return Ok(true);
6137            }
6138        }
6139        Ok(_) => return Ok(true),
6140        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6141        Err(error) => {
6142            return Err(spar_err!(
6143                "could not inspect {}: {error}",
6144                edit_message.display()
6145            ))
6146        }
6147    }
6148
6149    if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
6150        return Ok(true);
6151    }
6152
6153    let local_refs = run_git_bytes(
6154        cwd,
6155        &[
6156            "for-each-ref",
6157            "--format=%(refname)",
6158            "refs/worktree",
6159            "refs/bisect",
6160            "refs/rewritten",
6161        ],
6162    )?;
6163    if !local_refs.is_empty() {
6164        return Ok(true);
6165    }
6166
6167    for entry in std::fs::read_dir(&git_dir)
6168        .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
6169    {
6170        let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
6171        let known = matches!(
6172            entry.file_name().to_str(),
6173            Some(
6174                "HEAD"
6175                    | "ORIG_HEAD"
6176                    | "COMMIT_EDITMSG"
6177                    | "commondir"
6178                    | "gitdir"
6179                    | "index"
6180                    | "logs"
6181                    | "refs"
6182            )
6183        );
6184        if !known {
6185            return Ok(true);
6186        }
6187    }
6188
6189    let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6190    if !commit_has_shared_ref(cwd, head.trim())? {
6191        return Ok(true);
6192    }
6193    Ok(false)
6194}
6195
6196fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
6197    let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
6198    let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
6199        bail!(
6200            "git returned a commit without a message separator in {}",
6201            cwd.display()
6202        );
6203    };
6204    Ok(commit[split + 2..].to_vec())
6205}
6206
6207fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
6208    let metadata = match std::fs::symlink_metadata(logs) {
6209        Ok(metadata) => metadata,
6210        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6211        Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
6212    };
6213    if !metadata.is_dir() {
6214        return Ok(true);
6215    }
6216    let mut files = Vec::new();
6217    let mut directories = vec![logs.to_path_buf()];
6218    while let Some(directory) = directories.pop() {
6219        for entry in std::fs::read_dir(&directory)
6220            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
6221        {
6222            let entry =
6223                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
6224            let path = entry.path();
6225            let kind = entry
6226                .file_type()
6227                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
6228            if kind.is_dir() {
6229                directories.push(path);
6230            } else if kind.is_file() {
6231                files.push(path);
6232            } else {
6233                return Ok(true);
6234            }
6235        }
6236    }
6237
6238    let mut commits = BTreeSet::new();
6239    for path in files {
6240        if !collect_reflog_commits(cwd, &path, &mut commits)? {
6241            return Ok(true);
6242        }
6243    }
6244    for commit in commits {
6245        if !commit_has_shared_ref(cwd, &commit)? {
6246            return Ok(true);
6247        }
6248    }
6249    Ok(false)
6250}
6251
6252/// Every commit named by one ref's common reflog must survive deletion of that
6253/// ref. Ancestors of a durable current tip survive with the tip; divergent
6254/// entries need another shared ref of their own.
6255fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
6256    let common = common_git_dir(cwd)?;
6257    let reflog = common.join("logs").join(refname);
6258    let metadata = match std::fs::symlink_metadata(&reflog) {
6259        Ok(metadata) => metadata,
6260        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
6261        Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
6262    };
6263    if !metadata.is_file() {
6264        return Ok(false);
6265    }
6266    let mut commits = BTreeSet::new();
6267    if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
6268        return Ok(false);
6269    }
6270    for commit in commits {
6271        if is_ancestor(cwd, &commit, durable_tip)?
6272            || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
6273        {
6274            continue;
6275        }
6276        return Ok(false);
6277    }
6278    Ok(true)
6279}
6280
6281fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
6282    let file = std::fs::File::open(path)
6283        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6284    for line in std::io::BufReader::new(file).lines() {
6285        let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6286        let mut fields = line.splitn(3, ' ');
6287        let Some(old) = fields.next() else {
6288            return Ok(false);
6289        };
6290        let Some(new) = fields.next() else {
6291            return Ok(false);
6292        };
6293        if fields.next().is_none() {
6294            return Ok(false);
6295        }
6296        for oid in [old, new] {
6297            if oid.bytes().all(|byte| byte == b'0') {
6298                continue;
6299            }
6300            let Some(commit) = resolve_optional_commit(cwd, oid)? else {
6301                return Ok(false);
6302            };
6303            commits.insert(commit);
6304        }
6305    }
6306    Ok(true)
6307}
6308
6309fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
6310    let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
6311    let path = PathBuf::from(raw.trim());
6312    let path = if path.is_absolute() {
6313        path
6314    } else {
6315        cwd.join(path)
6316    };
6317    std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
6318}
6319
6320fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
6321    let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
6322    let output = proc::exec(
6323        &argv,
6324        &ExecOpts::new()
6325            .cwd(cwd)
6326            .timeout_secs(30)
6327            .check(false)
6328            .stop_descendants(true),
6329    )?;
6330    match output.code {
6331        0 => Ok(true),
6332        1 => Ok(false),
6333        _ => bail!("{}", proc::failure_message(&argv, &output)),
6334    }
6335}
6336
6337fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
6338    let commit = format!("{oid}^{{commit}}");
6339    let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
6340    let output = proc::exec(
6341        &argv,
6342        &ExecOpts::new()
6343            .cwd(cwd)
6344            .timeout_secs(30)
6345            .check(false)
6346            .stop_descendants(true),
6347    )?;
6348    if output.code != 0 {
6349        return Ok(None);
6350    }
6351    let oid = output.stdout.trim();
6352    if oid.is_empty() {
6353        return Ok(None);
6354    }
6355    Ok(Some(oid.to_string()))
6356}
6357
6358fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
6359    commit_has_shared_ref_except(cwd, oid, None)
6360}
6361
6362fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
6363    let contains = format!("--contains={oid}");
6364    let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
6365    Ok(shared.split(|byte| *byte == b'\n').any(|record| {
6366        !record.is_empty()
6367            && !record.starts_with(b"refs/worktree/")
6368            && !record.starts_with(b"refs/bisect/")
6369            && !record.starts_with(b"refs/rewritten/")
6370            && exclude.is_none_or(|excluded| record != excluded.as_bytes())
6371    }))
6372}
6373
6374/// Whether removing the worktree would take away an untracked file somebody
6375/// might want back.
6376///
6377/// Ordinary untracked files always count. So does an ignored file outside the
6378/// known build and cache directories, because an ignored path is only a path
6379/// Git was told not to track, which is where a local `.env` lives as readily as
6380/// compiler output.
6381///
6382/// Recognized build and cache output does not. A managed commit already leaves
6383/// it out rather than treating it as work, and the command that wrote it writes
6384/// it again. Counting it kept every worktree whose tests or build had run,
6385/// which is nearly all of them, so a merged pull request still left its
6386/// checkout behind. A repository nested in that output is somebody else's
6387/// history and counts whatever it sits under.
6388fn has_untracked_work_worth_keeping(cwd: &Path) -> Result<bool> {
6389    let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
6390    if !ordinary.is_empty() {
6391        return Ok(true);
6392    }
6393    let listed = untracked_listing(
6394        cwd,
6395        &[
6396            "ls-files",
6397            "--others",
6398            "--ignored",
6399            "--exclude-standard",
6400            "-z",
6401        ],
6402    )?;
6403    for raw in listed {
6404        let (path, nested) = untracked_record(&raw, "ignored")?;
6405        if nested || !is_generated_artifact(&path) {
6406            return Ok(true);
6407        }
6408    }
6409    Ok(false)
6410}
6411
6412fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
6413    let listed = run_git_bytes(cwd, args)?;
6414    if !listed.is_empty() && !listed.ends_with(&[0]) {
6415        bail!(
6416            "git returned an unterminated untracked-file list for {}",
6417            cwd.display()
6418        );
6419    }
6420    Ok(listed
6421        .split(|byte| *byte == 0)
6422        .filter(|raw| !raw.is_empty())
6423        .map(|raw| raw.to_vec())
6424        .collect())
6425}
6426
6427fn repository_has_recoverable_work_inner(
6428    cwd: &Path,
6429    include_ignored: bool,
6430    visited: &mut BTreeSet<PathBuf>,
6431) -> Result<bool> {
6432    let canonical = std::fs::canonicalize(cwd)
6433        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6434    if !visited.insert(canonical.clone()) {
6435        bail!("submodule recursion revisited {}", canonical.display());
6436    }
6437    if include_ignored && has_untracked_work_worth_keeping(cwd)? {
6438        return Ok(true);
6439    }
6440    if !unsafe_index_flags(cwd)?.is_empty() {
6441        return Ok(true);
6442    }
6443    if attributes_may_be_modified(cwd)? {
6444        return Ok(true);
6445    }
6446    if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
6447        return Ok(true);
6448    }
6449    if include_ignored {
6450        let index = index_entries(cwd)?
6451            .into_iter()
6452            .map(|entry| (entry.path, (entry.mode, entry.oid)))
6453            .collect::<BTreeMap<_, _>>();
6454        let head = tree_entries(cwd, "HEAD")?
6455            .into_iter()
6456            .map(|entry| (entry.path, (entry.mode, entry.oid)))
6457            .collect::<BTreeMap<_, _>>();
6458        if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
6459            return Ok(true);
6460        }
6461        let tracked = tracked_entries(cwd)?;
6462        let effective = check_attributes(cwd, tracked.keys().cloned())?;
6463        let config = CheckoutConfig::read(cwd)?;
6464        for (path, entry) in tracked {
6465            let Some(worktree) = entry.worktree else {
6466                return Ok(true);
6467            };
6468            let attributes = effective.get(&path).ok_or_else(|| {
6469                spar_err!("git omitted attributes for {}", cwd.join(&path).display())
6470            })?;
6471            if path_has_ambiguous_transform(&config, attributes)? {
6472                return Ok(true);
6473            }
6474            let symlink_file = entry.index_mode == "120000"
6475                && worktree.mode == "100644"
6476                && worktree.raw_oid == entry.index_oid
6477                && config.symlinks == Some(false);
6478            if worktree.mode != entry.index_mode && !symlink_file {
6479                return Ok(true);
6480            }
6481            if entry.index_mode == "120000" {
6482                if worktree.raw_oid != entry.index_oid {
6483                    return Ok(true);
6484                }
6485                continue;
6486            }
6487            #[cfg(unix)]
6488            {
6489                let expected = if entry.index_mode == "100755" {
6490                    0o755
6491                } else {
6492                    0o644
6493                };
6494                if worktree.permissions != expected {
6495                    return Ok(true);
6496                }
6497            }
6498            if allows_expected_crlf(&config, attributes)? {
6499                let (normalized, every_lf_was_crlf) =
6500                    normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
6501                if !every_lf_was_crlf || normalized != entry.index_oid {
6502                    return Ok(true);
6503                }
6504            } else if worktree.raw_oid != entry.index_oid {
6505                return Ok(true);
6506            }
6507        }
6508    } else {
6509        let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
6510        if !run_git_bytes(cwd, &args)?.is_empty() {
6511            return Ok(true);
6512        }
6513    }
6514    for link in gitlinks(cwd)? {
6515        let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
6516            continue;
6517        };
6518        // Git stores a linked worktree's initialized submodule objects under
6519        // that worktree's administrative directory. Ordinary removal cannot
6520        // prove a local submodule commit exists anywhere else, even when both
6521        // working trees look clean.
6522        if include_ignored {
6523            return Ok(true);
6524        }
6525        let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6526        if head.trim() != link.oid {
6527            return Ok(true);
6528        }
6529        if repository_has_recoverable_work_inner(&submodule, include_ignored, visited)? {
6530            return Ok(true);
6531        }
6532    }
6533    Ok(false)
6534}
6535
6536pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
6537    repository_has_recoverable_work(cwd, false)
6538}
6539
6540fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
6541    let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
6542    Ok(!run_git_bytes(cwd, &args)?.is_empty())
6543}
6544
6545#[cfg(unix)]
6546fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6547    use std::os::unix::ffi::OsStringExt;
6548    Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
6549}
6550
6551#[cfg(not(unix))]
6552fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6553    String::from_utf8(raw.to_vec())
6554        .map(PathBuf::from)
6555        .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
6556}
6557
6558/// Fingerprint the directory of a nested repository without reading inside it.
6559///
6560/// The files under it are that repository's, not this one's. They are recorded
6561/// against its own baseline whenever SPAR works there, and a run of its own may
6562/// legitimately add or remove entries while this call is in flight, so the
6563/// volatile directory fields stay out of the fingerprint. Identity and type
6564/// remain, which is what makes deleting the checkout, or replacing it with a
6565/// file, observable from the outer worktree.
6566fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
6567    let metadata = std::fs::symlink_metadata(path).map_err(|e| {
6568        spar_err!(
6569            "could not inspect the nested repository at {}: {e}",
6570            path.display()
6571        )
6572    })?;
6573    if !metadata.is_dir() {
6574        bail!(
6575            "git reported {} as a nested repository, but it is not a directory",
6576            path.display()
6577        );
6578    }
6579    if std::fs::symlink_metadata(path.join(".git")).is_err() {
6580        bail!(
6581            "git reported {} as a nested repository, but it has no Git entry",
6582            path.display()
6583        );
6584    }
6585    #[cfg(unix)]
6586    {
6587        use std::os::unix::fs::MetadataExt;
6588        Ok(UntrackedFile {
6589            kind: 3,
6590            len: 0,
6591            modified: None,
6592            created: metadata.created().ok(),
6593            readonly: metadata.permissions().readonly(),
6594            symlink_target: None,
6595            device: metadata.dev(),
6596            inode: metadata.ino(),
6597            mode: metadata.mode(),
6598            change_seconds: 0,
6599            change_nanoseconds: 0,
6600        })
6601    }
6602    #[cfg(not(unix))]
6603    {
6604        Ok(UntrackedFile {
6605            kind: 3,
6606            len: 0,
6607            modified: None,
6608            created: metadata.created().ok(),
6609            readonly: metadata.permissions().readonly(),
6610            symlink_target: None,
6611        })
6612    }
6613}
6614
6615fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
6616    let metadata = std::fs::symlink_metadata(path)
6617        .map_err(|e| spar_err!("could not inspect untracked file {}: {e}", path.display()))?;
6618    let kind = if metadata.file_type().is_symlink() {
6619        2
6620    } else if metadata.is_file() {
6621        1
6622    } else {
6623        bail!(
6624            "untracked path {} is not a regular file or symlink",
6625            path.display()
6626        );
6627    };
6628    let symlink_target = if kind == 2 {
6629        let target = std::fs::read_link(path)
6630            .map_err(|e| spar_err!("could not read untracked symlink {}: {e}", path.display()))?;
6631        Some(os_str_bytes(target.as_os_str())?)
6632    } else {
6633        None
6634    };
6635    #[cfg(unix)]
6636    {
6637        use std::os::unix::fs::MetadataExt;
6638        Ok(UntrackedFile {
6639            kind,
6640            len: metadata.len(),
6641            modified: metadata.modified().ok(),
6642            created: metadata.created().ok(),
6643            readonly: metadata.permissions().readonly(),
6644            symlink_target,
6645            device: metadata.dev(),
6646            inode: metadata.ino(),
6647            mode: metadata.mode(),
6648            change_seconds: metadata.ctime(),
6649            change_nanoseconds: metadata.ctime_nsec(),
6650        })
6651    }
6652    #[cfg(not(unix))]
6653    {
6654        Ok(UntrackedFile {
6655            kind,
6656            len: metadata.len(),
6657            modified: metadata.modified().ok(),
6658            created: metadata.created().ok(),
6659            readonly: metadata.permissions().readonly(),
6660            symlink_target,
6661        })
6662    }
6663}
6664
6665#[cfg(unix)]
6666fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6667    use std::os::unix::ffi::OsStrExt;
6668    Ok(value.as_bytes().to_vec())
6669}
6670
6671#[cfg(not(unix))]
6672fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
6673    value
6674        .to_str()
6675        .map(|value| value.as_bytes().to_vec())
6676        .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
6677}
6678
6679#[cfg(unix)]
6680fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6681    use std::os::unix::fs::MetadataExt;
6682    right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
6683}
6684
6685#[cfg(not(unix))]
6686fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6687    right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
6688}
6689
6690#[derive(Debug, Clone, serde::Serialize, Deserialize)]
6691pub struct BranchRecord {
6692    pub kind: String,
6693    pub number: i64,
6694}
6695
6696/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
6697/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
6698pub fn review_ref(number: i64) -> String {
6699    format!("refs/spar/pr-{number}")
6700}
6701
6702pub fn is_finished(state: &str) -> bool {
6703    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
6704}
6705
6706/// Write text through a temporary file and rename, so a kill cannot leave a
6707/// truncated file behind.
6708///
6709/// The follow-up queue is the one file spar rewrites in place rather than
6710/// appends to, and a truncated queue is lost work: what it held was never
6711/// written anywhere else.
6712pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
6713    if let Some(parent) = path.parent() {
6714        std::fs::create_dir_all(parent)
6715            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6716    }
6717    // The extension defaults to `json` so `clear_state`, which removes a
6718    // leftover `pr-N.json.tmp` by name, keeps finding the one this wrote.
6719    let tmp = path.with_extension(format!(
6720        "{}.tmp",
6721        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
6722    ));
6723    std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
6724    std::fs::rename(&tmp, path)
6725        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
6726    Ok(())
6727}
6728
6729/// Whether a follow-up file already has an entry under this exact title.
6730///
6731/// Whole heading lines, case insensitively. A substring check answered yes
6732/// for "Retry loop" against "## Retry loop never terminates", which is a
6733/// different defect, and the new one was dropped with a line saying it was
6734/// already noted.
6735pub fn has_heading(text: &str, title: &str) -> bool {
6736    let wanted = title.trim().to_lowercase();
6737    if wanted.is_empty() {
6738        return false;
6739    }
6740    text.lines()
6741        .filter_map(|line| line.trim().strip_prefix("## "))
6742        .any(|heading| heading.trim().to_lowercase() == wanted)
6743}
6744
6745/// Write JSON through a temporary file and rename, so a kill cannot leave a
6746/// truncated state file behind.
6747pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
6748    write_text_atomic(path, &serde_json::to_string_pretty(value)?)
6749}
6750
6751/// One row of `gh pr list --json number,url,title,baseRefName,closingIssuesReferences`.
6752#[derive(Deserialize)]
6753#[serde(rename_all = "camelCase")]
6754pub struct LinkedPrRow {
6755    number: i64,
6756    #[serde(default)]
6757    url: String,
6758    #[serde(default)]
6759    title: String,
6760    #[serde(default)]
6761    closing_issues_references: Vec<IssueRef>,
6762}
6763
6764/// The listed pull requests, or `None` when the payload could not be read.
6765///
6766/// Separated from the gh call so the real payload shape can be tested, and kept
6767/// distinct from an empty list, because silently failing to parse would look
6768/// exactly like "no pull request exists", which is the answer that makes spar
6769/// implement over the top of somebody's work.
6770pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
6771    serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
6772}
6773
6774/// Among the listed pull requests, the first that would close `issue`.
6775pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
6776    rows.iter()
6777        .find(|row| {
6778            row.closing_issues_references
6779                .iter()
6780                .any(|linked| linked.number == issue)
6781        })
6782        .map(|row| PrRef {
6783            number: row.number,
6784            url: row.url.clone(),
6785            title: row.title.clone(),
6786        })
6787}
6788
6789/// Both halves together, for the tests that hold the real payload.
6790pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
6791    pick_linked_pr(&linked_pr_rows(json)?, issue)
6792}
6793
6794/// Flatten whatever `gh api --paginate` printed into a list of comments.
6795///
6796/// Current gh merges array pages into one array. Older builds concatenated one
6797/// document per page. A streaming parser reads either, and unlike splitting the
6798/// text on a bracket pair it cannot be fooled by a comment body that happens to
6799/// contain one, which would otherwise make a resume silently start over.
6800fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
6801    if text.trim().is_empty() {
6802        return Err(spar_err!("GitHub returned no comment data"));
6803    }
6804    let mut out = Vec::new();
6805    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
6806        match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
6807            Value::Array(items) => out.extend(items),
6808            _ => return Err(spar_err!("unexpected non-array comment page")),
6809        }
6810    }
6811    Ok(out)
6812}
6813
6814pub fn parse_comment_pages(text: &str) -> Vec<Value> {
6815    let mut out = Vec::new();
6816    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
6817        match value {
6818            Ok(Value::Array(items)) => out.extend(items),
6819            Ok(other) => out.push(other),
6820            Err(_) => break,
6821        }
6822    }
6823    out
6824}
6825
6826/// Whether a comment is a state block spar itself wrote.
6827///
6828/// The body has to open with the marker, so a person quoting it in a sentence
6829/// is not one; it has to parse, so a half-quoted block is not one; and it has
6830/// to be the viewer's own comment, the way `comments.rs` already tells spar's
6831/// writes apart. Anything less and `write_pr_state` patches somebody's comment
6832/// with JSON, and `spar clean --pr-state` later deletes it.
6833pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
6834    body.trim_start().starts_with(STATE_MARKER)
6835        && !viewer.trim().is_empty()
6836        && author.trim().eq_ignore_ascii_case(viewer.trim())
6837        && parse_state_comment(body).is_some()
6838}
6839
6840/// Extract the payload from a state comment. The marker is followed by JSON and
6841/// terminated with `-->`.
6842pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
6843    let marker = body.find(STATE_MARKER)?;
6844    let start = body[marker..].find('{')? + marker;
6845    let end = body.rfind('}')?;
6846    if end <= start {
6847        return None;
6848    }
6849    match serde_json::from_str(&body[start..=end]) {
6850        Ok(state) => Some(state),
6851        Err(_) => {
6852            logdim!("found a spar state comment but could not parse it");
6853            None
6854        }
6855    }
6856}
6857
6858fn choose_state_for_head(
6859    candidates: Vec<PersistedState>,
6860    actual_head: &str,
6861) -> Option<PersistedState> {
6862    let matching: Vec<PersistedState> = candidates
6863        .iter()
6864        .filter(|state| state.pr_head == actual_head)
6865        .cloned()
6866        .collect();
6867    if !matching.is_empty() {
6868        return newest_state(matching);
6869    }
6870    newest_state(candidates)
6871}
6872
6873fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
6874    candidates.into_iter().reduce(|best, candidate| {
6875        if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
6876            candidate
6877        } else {
6878            // The local candidate is supplied first. Keeping the first exact
6879            // tie recovers correctly from a local write followed by a failed
6880            // pull request state update, including legacy states with no
6881            // checkpoint field.
6882            best
6883        }
6884    })
6885}
6886
6887/// Where this binary lives, so `git filter-branch` can call back into it.
6888///
6889/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
6890/// tests, whose `current_exe` is the test harness rather than spar, and for
6891/// anyone who ships spar behind a wrapper script.
6892pub fn self_binary() -> Result<PathBuf> {
6893    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
6894        let path = PathBuf::from(path);
6895        if proc::is_executable(&path) {
6896            return Ok(path);
6897        }
6898        bail!(
6899            "SPAR_SELF_BIN is set to {}, which is not executable",
6900            path.display()
6901        );
6902    }
6903    std::env::current_exe()
6904        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
6905}
6906
6907fn bool_env(value: bool) -> &'static str {
6908    if value {
6909        "1"
6910    } else {
6911        "0"
6912    }
6913}
6914
6915/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
6916/// shell command, and an install path with a space in it is not exotic.
6917pub fn sh_quote(text: &str) -> String {
6918    format!("'{}'", text.replace('\'', r"'\''"))
6919}
6920
6921/// Style rules for the `scrub-filter` subcommand, which runs in a child process
6922/// spawned by git and so cannot see the parent's config.
6923pub fn style_from_env() -> Style {
6924    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
6925    Style {
6926        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
6927        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
6928        ..Style::permissive()
6929    }
6930}
6931
6932#[cfg(test)]
6933mod tests {
6934    use super::*;
6935    use crate::config::StateStore;
6936    use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
6937    use std::process::Command;
6938
6939    fn repo_for_titles() -> Repo {
6940        Repo {
6941            root: PathBuf::from("/nonexistent"),
6942            style: Style::default(),
6943            branch_prefix: String::new(),
6944            state_store: StateStore::Local,
6945            followups: crate::config::Followups::Issues,
6946            drafts: Drafts::Never,
6947            viewer: OnceLock::new(),
6948            checkpoints: Mutex::new(BTreeMap::new()),
6949            writes: WriteStats::default(),
6950        }
6951    }
6952
6953    #[test]
6954    fn write_results_accumulate_for_the_run() {
6955        let repo = repo_for_titles();
6956
6957        let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
6958        let _: std::result::Result<(), ()> = repo.record_write(Err(()));
6959
6960        assert_eq!(
6961            WriteSummary {
6962                attempted: 2,
6963                failed: 1,
6964            },
6965            repo.write_summary()
6966        );
6967    }
6968
6969    #[test]
6970    fn only_failed_write_preflights_join_the_summary() {
6971        let repo = repo_for_titles();
6972
6973        let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
6974        let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
6975
6976        assert_eq!(
6977            WriteSummary {
6978                attempted: 1,
6979                failed: 1,
6980            },
6981            repo.write_summary()
6982        );
6983    }
6984
6985    #[test]
6986    fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
6987        let repo = repo_for_titles();
6988
6989        assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
6990        assert_eq!(
6991            WriteSummary {
6992                attempted: 1,
6993                failed: 1,
6994            },
6995            repo.write_summary()
6996        );
6997    }
6998
6999    #[test]
7000    fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
7001        let mut repo = repo_for_titles();
7002        repo.followups = Followups::Local;
7003
7004        assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
7005        assert_eq!(WriteSummary::default(), repo.write_summary());
7006    }
7007
7008    #[test]
7009    fn a_failed_remote_state_read_stops_before_state_mutation() {
7010        let root = std::env::temp_dir().join(format!(
7011            "spar-state-preflight-{}-{}",
7012            std::process::id(),
7013            std::time::SystemTime::now()
7014                .duration_since(std::time::UNIX_EPOCH)
7015                .unwrap()
7016                .as_nanos()
7017        ));
7018        std::fs::create_dir_all(&root).unwrap();
7019        let _fixture = ReviewFixture { root: root.clone() };
7020        let mut repo = repo_for_titles();
7021        repo.root = root;
7022        repo.state_store = StateStore::Both;
7023        let state = PersistedState {
7024            version: 1,
7025            checkpoint: 4,
7026            round: 2,
7027            next_actor: "a".into(),
7028            status: Status::Pending,
7029            pr_head: "abc123".into(),
7030            ledger: Ledger::new(),
7031            filed: Vec::new(),
7032            open_findings: Vec::new(),
7033            disputes: Vec::new(),
7034            noted: Vec::new(),
7035        };
7036
7037        let error = repo
7038            .write_state_after_remote_read(
7039                7,
7040                &state,
7041                Err(crate::error::SparError::new("state comments unavailable")),
7042            )
7043            .unwrap_err();
7044
7045        assert!(error.to_string().contains("state comments unavailable"));
7046        assert!(!repo.state_path(7).exists());
7047        assert_eq!(0, repo.remembered_checkpoint(7));
7048        assert_eq!(
7049            WriteSummary {
7050                attempted: 1,
7051                failed: 1,
7052            },
7053            repo.write_summary()
7054        );
7055    }
7056
7057    #[test]
7058    fn only_known_build_and_cache_directories_are_generated_artifacts() {
7059        assert!(is_generated_artifact(Path::new("target/debug/artifact")));
7060        assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
7061        assert!(is_generated_artifact(Path::new(
7062            "package/node_modules/dependency/file.js"
7063        )));
7064        assert!(!is_generated_artifact(Path::new(
7065            "distribution/required-package.js"
7066        )));
7067        assert!(!is_generated_artifact(Path::new(
7068            "generated/required-fixture.txt"
7069        )));
7070        assert!(!is_generated_artifact(Path::new("local.env")));
7071    }
7072
7073    struct ReviewFixture {
7074        root: PathBuf,
7075    }
7076
7077    impl Drop for ReviewFixture {
7078        fn drop(&mut self) {
7079            let _ = std::fs::remove_dir_all(&self.root);
7080        }
7081    }
7082
7083    fn test_git(cwd: &Path, args: &[&str]) -> String {
7084        let output = Command::new("git")
7085            .args(args)
7086            .current_dir(cwd)
7087            .output()
7088            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
7089        assert!(
7090            output.status.success(),
7091            "git {args:?} failed: {}",
7092            String::from_utf8_lossy(&output.stderr)
7093        );
7094        String::from_utf8_lossy(&output.stdout).into_owned()
7095    }
7096
7097    fn review_fixture(
7098        tag: &str,
7099        number: i64,
7100    ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
7101        use std::sync::atomic::{AtomicU32, Ordering};
7102        static NEXT: AtomicU32 = AtomicU32::new(0);
7103        let id = NEXT.fetch_add(1, Ordering::Relaxed);
7104        let root =
7105            std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
7106        let origin = root.join("origin.git");
7107        let work = root.join("work");
7108        std::fs::create_dir_all(&origin).unwrap();
7109        std::fs::create_dir_all(&work).unwrap();
7110        test_git(&origin, &["init", "--bare", "-b", "main"]);
7111        test_git(&work, &["init", "-b", "main"]);
7112        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
7113        test_git(&work, &["config", "user.name", "spar test"]);
7114        test_git(&work, &["config", "commit.gpgsign", "false"]);
7115        test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
7116        test_git(&work, &["config", "filter.drop.smudge", "cat"]);
7117        std::fs::write(work.join("README.md"), "seed\n").unwrap();
7118        std::fs::write(work.join("data.txt"), "old\n").unwrap();
7119        std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
7120        std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
7121        test_git(&work, &["add", "."]);
7122        test_git(&work, &["commit", "-m", "seed"]);
7123        test_git(
7124            &work,
7125            &["remote", "add", "origin", origin.to_str().unwrap()],
7126        );
7127        test_git(&work, &["push", "-u", "origin", "main"]);
7128        test_git(
7129            &work,
7130            &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
7131        );
7132        let cfg = crate::config::parse(
7133            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
7134        )
7135        .unwrap();
7136        let repo = Repo::open(&work, &cfg).unwrap();
7137        let path = repo.worktree_for_pr_head(number).unwrap();
7138        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7139        (ReviewFixture { root }, repo, path, checkpoint)
7140    }
7141
7142    #[test]
7143    fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
7144        let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
7145
7146        repo.release_review_worktree_checked(901, &checkpoint)
7147            .unwrap();
7148
7149        assert!(!path.exists());
7150    }
7151
7152    #[test]
7153    fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
7154        let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
7155        let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
7156        std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
7157        test_git(&path, &["add", "recovery.txt"]);
7158        test_git(&path, &["commit", "-m", "recovery commit"]);
7159        let recovery = test_git(&path, &["rev-parse", "HEAD"]);
7160        test_git(&path, &["reset", "--hard", "main"]);
7161
7162        assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
7163        test_git(
7164            &path,
7165            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7166        );
7167    }
7168
7169    #[test]
7170    fn a_review_ref_reflog_only_commit_prevents_deletion() {
7171        let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
7172        let local_ref = review_ref(921);
7173        let original = test_git(&path, &["rev-parse", &local_ref]);
7174        let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
7175        let recovery = test_git(
7176            &path,
7177            &[
7178                "commit-tree",
7179                tree.trim(),
7180                "-p",
7181                original.trim(),
7182                "-m",
7183                "review ref recovery",
7184            ],
7185        );
7186        test_git(
7187            &path,
7188            &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
7189        );
7190        test_git(
7191            &path,
7192            &["update-ref", &local_ref, original.trim(), recovery.trim()],
7193        );
7194
7195        assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
7196        assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
7197        test_git(
7198            &path,
7199            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7200        );
7201    }
7202
7203    #[test]
7204    fn an_unpublished_commit_message_draft_is_recoverable() {
7205        let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
7206        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
7207        let git_dir = if raw.is_absolute() {
7208            raw
7209        } else {
7210            path.join(raw)
7211        };
7212        std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
7213
7214        assert!(repository_has_recoverable_work(&path, true).unwrap());
7215        assert_eq!(
7216            "unique recovery draft\n",
7217            std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
7218        );
7219    }
7220
7221    #[test]
7222    fn a_changed_review_worktree_is_retained_after_a_checked_read() {
7223        let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
7224        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
7225
7226        let error = repo
7227            .release_review_worktree_checked(902, &checkpoint)
7228            .unwrap_err();
7229
7230        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7231        assert!(error.to_string().contains("kept for recovery"), "{error}");
7232        assert_eq!(
7233            "recover me\n",
7234            std::fs::read_to_string(path.join("README.md")).unwrap()
7235        );
7236        repo.release_review_worktree(902);
7237    }
7238
7239    #[test]
7240    fn a_review_commit_is_retained_after_a_checked_read() {
7241        let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
7242        std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
7243        test_git(&path, &["add", "review-note.txt"]);
7244        test_git(&path, &["commit", "-m", "local review recovery"]);
7245        let head = test_git(&path, &["rev-parse", "HEAD"]);
7246
7247        let error = repo
7248            .release_review_worktree_checked(903, &checkpoint)
7249            .unwrap_err();
7250
7251        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7252        assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
7253        assert_eq!(
7254            "recover me\n",
7255            std::fs::read_to_string(path.join("review-note.txt")).unwrap()
7256        );
7257        repo.release_review_worktree(903);
7258    }
7259
7260    #[test]
7261    fn an_ignored_review_file_is_retained_after_a_checked_read() {
7262        let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
7263        std::fs::create_dir_all(path.join("generated")).unwrap();
7264        std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
7265
7266        let error = repo
7267            .release_review_worktree_checked(904, &checkpoint)
7268            .unwrap_err();
7269
7270        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7271        assert_eq!(
7272            "recover me\n",
7273            std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
7274        );
7275        repo.release_review_worktree(904);
7276    }
7277
7278    #[test]
7279    fn a_preexisting_ignored_review_file_change_is_retained() {
7280        let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
7281        std::fs::create_dir_all(path.join("generated")).unwrap();
7282        let ignored = path.join("generated/recovery.txt");
7283        std::fs::write(&ignored, "before\n").unwrap();
7284        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7285        std::fs::write(&ignored, "after!\n").unwrap();
7286
7287        let error = repo
7288            .release_review_worktree_checked(905, &checkpoint)
7289            .unwrap_err();
7290
7291        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7292        assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
7293        repo.release_review_worktree(905);
7294    }
7295
7296    #[test]
7297    fn a_preexisting_ignored_review_file_prevents_checked_removal() {
7298        let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
7299        std::fs::create_dir_all(path.join("generated")).unwrap();
7300        let ignored = path.join("generated/recovery.txt");
7301        std::fs::write(&ignored, "keep me\n").unwrap();
7302        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7303
7304        let error = repo
7305            .release_review_worktree_checked(906, &checkpoint)
7306            .unwrap_err();
7307
7308        assert!(error.to_string().contains("recoverable"), "{error}");
7309        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7310    }
7311
7312    #[test]
7313    fn overwriting_a_preexisting_untracked_file_is_detected() {
7314        let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
7315        let untracked = path.join("notes.txt");
7316        std::fs::write(&untracked, "before\n").unwrap();
7317        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7318        std::fs::write(&untracked, "after!\n").unwrap();
7319
7320        let error = repo
7321            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7322            .unwrap_err();
7323
7324        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7325        assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
7326    }
7327
7328    #[test]
7329    fn an_assume_unchanged_edit_is_detected() {
7330        let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
7331        test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
7332        std::fs::write(path.join("README.md"), "hidden\n").unwrap();
7333
7334        let error = repo
7335            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7336            .unwrap_err();
7337
7338        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7339        assert_eq!(
7340            "hidden\n",
7341            std::fs::read_to_string(path.join("README.md")).unwrap()
7342        );
7343    }
7344
7345    #[test]
7346    fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
7347        let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
7348        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7349        test_git(&path, &["add", "README.md"]);
7350        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7351
7352        let error = repo
7353            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7354            .unwrap_err();
7355
7356        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7357        assert_eq!(
7358            b"seed\r\n",
7359            std::fs::read(path.join("README.md")).unwrap().as_slice()
7360        );
7361    }
7362
7363    #[cfg(unix)]
7364    #[test]
7365    fn a_mode_edit_is_detected_when_filemode_is_disabled() {
7366        use std::os::unix::fs::PermissionsExt;
7367
7368        let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
7369        test_git(&path, &["config", "core.filemode", "false"]);
7370        let readme = path.join("README.md");
7371        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7372        permissions.set_mode(0o755);
7373        std::fs::set_permissions(&readme, permissions).unwrap();
7374        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7375
7376        let error = repo
7377            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7378            .unwrap_err();
7379
7380        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7381        assert_eq!(
7382            0o755,
7383            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7384        );
7385    }
7386
7387    #[test]
7388    fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
7389        let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
7390        std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
7391        test_git(&path, &["add", ".gitattributes"]);
7392        test_git(&path, &["commit", "-m", "select data filter"]);
7393        let baseline = repo.worktree_baseline(&path).unwrap();
7394        std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
7395
7396        assert!(repo
7397            .commit_pending_changes(&path, &baseline, "change data", "change data")
7398            .unwrap());
7399        let error = repo
7400            .refuse_unrepresented_tracked_changes(&path, &baseline)
7401            .unwrap_err();
7402
7403        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7404        assert_eq!(
7405            "secret: recover me\nnew\n",
7406            std::fs::read_to_string(path.join("data.txt")).unwrap()
7407        );
7408        assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
7409    }
7410
7411    #[test]
7412    fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
7413        let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
7414        std::fs::create_dir_all(path.join("target")).unwrap();
7415        let untracked = path.join("target/user.yaml");
7416        std::fs::write(&untracked, "user data\n").unwrap();
7417        let baseline = repo.worktree_baseline(&path).unwrap();
7418        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7419
7420        assert!(repo
7421            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7422            .unwrap());
7423
7424        assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
7425        assert_eq!(
7426            "?? target/user.yaml\n",
7427            test_git(&path, &["status", "--short", "--untracked-files=all"])
7428        );
7429        assert!(test_git(
7430            &path,
7431            &[
7432                "ls-tree",
7433                "-r",
7434                "--name-only",
7435                "HEAD",
7436                "--",
7437                "target/user.yaml"
7438            ]
7439        )
7440        .is_empty());
7441    }
7442
7443    #[test]
7444    fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
7445        let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
7446        std::fs::create_dir_all(path.join("target")).unwrap();
7447        let untracked = path.join("target/user.yaml");
7448        std::fs::write(&untracked, "before\n").unwrap();
7449        let baseline = repo.worktree_baseline(&path).unwrap();
7450        let before = test_git(&path, &["rev-parse", "HEAD"]);
7451        std::fs::write(&untracked, "after\n").unwrap();
7452        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7453
7454        let error = repo
7455            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7456            .unwrap_err();
7457
7458        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7459        assert!(error.to_string().contains("target/user.yaml"), "{error}");
7460        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7461        assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
7462        assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
7463    }
7464
7465    #[test]
7466    fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
7467        let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
7468        let baseline = repo.worktree_baseline(&path).unwrap();
7469        std::fs::create_dir_all(path.join("target")).unwrap();
7470        std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
7471
7472        assert!(repo
7473            .commit_pending_changes(&path, &baseline, "add file", "add file")
7474            .unwrap());
7475
7476        assert_eq!(
7477            "new file\n",
7478            test_git(&path, &["show", "HEAD:target/new.txt"])
7479        );
7480        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7481    }
7482
7483    #[test]
7484    fn deleting_existing_ignored_work_stops_a_managed_commit() {
7485        let (_fixture, repo, path, _checkpoint) = review_fixture("deleted-ignored", 912);
7486        std::fs::create_dir_all(path.join("generated")).unwrap();
7487        let ignored = path.join("generated/keep.txt");
7488        std::fs::write(&ignored, "user data\n").unwrap();
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::remove_file(&ignored).unwrap();
7493
7494        let error = repo
7495            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7496            .unwrap_err();
7497
7498        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7499        assert!(error.to_string().contains("existing untracked"), "{error}");
7500        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7501        assert_eq!(
7502            "tracked change\n",
7503            std::fs::read_to_string(path.join("README.md")).unwrap()
7504        );
7505    }
7506
7507    #[test]
7508    fn new_ignored_work_stops_a_managed_commit_with_tracked_changes() {
7509        let (_fixture, repo, path, _checkpoint) = review_fixture("mixed-ignored", 926);
7510        let baseline = repo.worktree_baseline(&path).unwrap();
7511        let before = test_git(&path, &["rev-parse", "HEAD"]);
7512        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7513        std::fs::create_dir_all(path.join("generated")).unwrap();
7514        let ignored = path.join("generated/recovery.txt");
7515        std::fs::write(&ignored, "keep me\n").unwrap();
7516
7517        let error = repo
7518            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7519            .unwrap_err();
7520
7521        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7522        assert!(error.to_string().contains("recovery.txt"), "{error}");
7523        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7524        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7525        assert!(test_git(&path, &["status", "--porcelain"])
7526            .lines()
7527            .any(|line| line == "M  README.md"));
7528    }
7529
7530    #[test]
7531    fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
7532        let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
7533        test_git(&path, &["config", "core.autocrlf", "true"]);
7534        std::fs::write(path.join("README.md"), "seed\n").unwrap();
7535        assert_eq!(
7536            test_git(&path, &["hash-object", "README.md"]).trim(),
7537            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
7538        );
7539
7540        assert!(repository_has_recoverable_work(&path, true).unwrap());
7541        assert_eq!(
7542            "seed\n",
7543            std::fs::read_to_string(path.join("README.md")).unwrap()
7544        );
7545    }
7546
7547    #[test]
7548    fn autocrlf_input_overrides_a_crlf_core_eol() {
7549        let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
7550        test_git(&path, &["config", "core.autocrlf", "input"]);
7551        test_git(&path, &["config", "core.eol", "crlf"]);
7552        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7553        assert_eq!(
7554            test_git(&path, &["hash-object", "README.md"]).trim(),
7555            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
7556        );
7557
7558        assert!(repository_has_recoverable_work(&path, true).unwrap());
7559        assert_eq!(
7560            b"seed\r\n",
7561            std::fs::read(path.join("README.md")).unwrap().as_slice()
7562        );
7563    }
7564
7565    #[cfg(unix)]
7566    #[test]
7567    fn a_non_executable_permission_change_is_recoverable() {
7568        use std::os::unix::fs::PermissionsExt;
7569
7570        let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
7571        let readme = path.join("README.md");
7572        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7573        permissions.set_mode(0o600);
7574        std::fs::set_permissions(&readme, permissions).unwrap();
7575        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7576
7577        let error = repo
7578            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7579            .unwrap_err();
7580
7581        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7582        assert!(repository_has_recoverable_work(&path, true).unwrap());
7583        assert_eq!(
7584            0o600,
7585            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7586        );
7587    }
7588
7589    #[cfg(unix)]
7590    #[test]
7591    fn a_managed_commit_skips_signing_and_hooks() {
7592        use std::os::unix::fs::PermissionsExt;
7593
7594        let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
7595        let common = common_git_dir(&path).unwrap();
7596        let hook = common.join("hooks/pre-commit");
7597        let marker = fixture.root.join("hook-ran");
7598        std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
7599        std::fs::write(
7600            &hook,
7601            format!(
7602                "#!/bin/sh\nprintf ran > {}\nexit 1\n",
7603                sh_quote(marker.to_str().unwrap())
7604            ),
7605        )
7606        .unwrap();
7607        let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
7608        permissions.set_mode(0o755);
7609        std::fs::set_permissions(&hook, permissions).unwrap();
7610        test_git(&path, &["config", "commit.gpgsign", "true"]);
7611        test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
7612        std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
7613        test_git(&path, &["add", "managed.txt"]);
7614
7615        repo.commit_staged_changes(&path, "record managed change")
7616            .unwrap();
7617
7618        assert!(!marker.exists());
7619        assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
7620    }
7621
7622    #[test]
7623    fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
7624        let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
7625        std::fs::write(
7626            path.join(".gitattributes"),
7627            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
7628        )
7629        .unwrap();
7630        test_git(&path, &["add", ".gitattributes"]);
7631        test_git(&path, &["commit", "-m", "select automatic text"]);
7632        test_git(&path, &["config", "core.autocrlf", "true"]);
7633
7634        assert!(repository_has_recoverable_work(&path, true).unwrap());
7635    }
7636
7637    #[test]
7638    fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
7639        let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
7640        std::fs::write(
7641            path.join(".gitattributes"),
7642            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
7643        )
7644        .unwrap();
7645        test_git(&path, &["add", ".gitattributes"]);
7646        test_git(&path, &["commit", "-m", "select ident expansion"]);
7647        std::fs::write(path.join("README.md"), "seed\n").unwrap();
7648
7649        assert!(repository_has_recoverable_work(&path, true).unwrap());
7650    }
7651
7652    /// Add ignore rules the way SPAR does, without a tracked change the
7653    /// worktree would then be kept for.
7654    fn exclude_paths(repo: &Repo, lines: &[&str]) {
7655        use std::io::Write;
7656        let path = repo.root().join(".git").join("info").join("exclude");
7657        let mut file = std::fs::OpenOptions::new()
7658            .create(true)
7659            .append(true)
7660            .open(&path)
7661            .unwrap();
7662        for line in lines {
7663            writeln!(file, "{line}").unwrap();
7664        }
7665    }
7666
7667    #[test]
7668    fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
7669        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
7670        exclude_paths(&repo, &["dist/"]);
7671        std::fs::create_dir_all(path.join("dist")).unwrap();
7672        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
7673        let baseline = repo.worktree_baseline(&path).unwrap();
7674        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
7675        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
7676
7677        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
7678        repo.refuse_changed_existing_untracked(&path, &baseline)
7679            .unwrap();
7680    }
7681
7682    #[test]
7683    fn a_new_ignored_file_outside_build_output_still_stops_a_call() {
7684        let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
7685        exclude_paths(&repo, &["dist/", ".env.local"]);
7686        std::fs::create_dir_all(path.join("dist")).unwrap();
7687        let baseline = repo.worktree_baseline(&path).unwrap();
7688        std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
7689        std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
7690
7691        let error = repo.refuse_new_ignored_files(&path, &baseline).unwrap_err();
7692
7693        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7694        assert!(error.to_string().contains(".env.local"), "{error}");
7695    }
7696
7697    #[test]
7698    fn a_read_only_inspection_may_rebuild_generated_output() {
7699        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
7700        exclude_paths(&repo, &["dist/"]);
7701        std::fs::create_dir_all(path.join("dist")).unwrap();
7702        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
7703        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7704        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
7705        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
7706
7707        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7708            .unwrap();
7709    }
7710
7711    #[test]
7712    fn a_read_only_inspection_may_not_change_an_ignored_file_elsewhere() {
7713        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
7714        exclude_paths(&repo, &["dist/", ".env.local"]);
7715        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
7716        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7717        std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
7718
7719        let error = repo
7720            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7721            .unwrap_err();
7722
7723        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7724    }
7725
7726    #[test]
7727    fn build_output_alone_does_not_keep_a_worktree() {
7728        let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
7729        exclude_paths(&repo, &["target/", "dist/"]);
7730        std::fs::create_dir_all(path.join("target/debug")).unwrap();
7731        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
7732        std::fs::create_dir_all(path.join("dist/cli")).unwrap();
7733        std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
7734
7735        assert!(!repository_has_recoverable_work(&path, true).unwrap());
7736        repo.release_review_worktree(933);
7737
7738        assert!(!path.exists());
7739    }
7740
7741    #[test]
7742    fn an_ignored_file_outside_build_output_keeps_a_worktree() {
7743        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
7744        exclude_paths(&repo, &["target/", ".env.local"]);
7745        std::fs::create_dir_all(path.join("target/debug")).unwrap();
7746        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
7747        std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
7748
7749        assert!(repository_has_recoverable_work(&path, true).unwrap());
7750        repo.release_review_worktree(934);
7751
7752        assert_eq!(
7753            "TOKEN=keep me\n",
7754            std::fs::read_to_string(path.join(".env.local")).unwrap()
7755        );
7756    }
7757
7758    #[test]
7759    fn a_repository_nested_in_build_output_keeps_a_worktree() {
7760        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
7761        exclude_paths(&repo, &["node_modules/"]);
7762        let nested = path.join("node_modules/local-dep");
7763        std::fs::create_dir_all(&nested).unwrap();
7764        test_git(&nested, &["init"]);
7765        std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
7766
7767        assert!(repository_has_recoverable_work(&path, true).unwrap());
7768        repo.release_review_worktree(935);
7769
7770        assert!(nested.join(".git").exists());
7771    }
7772
7773    #[test]
7774    fn an_ordinary_untracked_file_keeps_a_worktree() {
7775        let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
7776        std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
7777
7778        assert!(repository_has_recoverable_work(&path, true).unwrap());
7779        repo.release_review_worktree(936);
7780
7781        assert_eq!(
7782            "somebody's notes\n",
7783            std::fs::read_to_string(path.join("notes.md")).unwrap()
7784        );
7785    }
7786
7787    #[test]
7788    fn a_legacy_crlf_checkout_is_retained_conservatively() {
7789        let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
7790        std::fs::write(
7791            path.join(".gitattributes"),
7792            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
7793        )
7794        .unwrap();
7795        test_git(&path, &["add", ".gitattributes"]);
7796        test_git(&path, &["commit", "-m", "select legacy line endings"]);
7797
7798        assert!(repository_has_recoverable_work(&path, true).unwrap());
7799    }
7800
7801    #[test]
7802    fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
7803        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
7804        let nested = path.join("tracked");
7805        std::fs::create_dir_all(&nested).unwrap();
7806        std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
7807        test_git(&path, &["add", "tracked/seed.txt"]);
7808        test_git(&path, &["commit", "-m", "add tracked directory"]);
7809        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7810        test_git(&nested, &["init"]);
7811
7812        let error = repo
7813            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7814            .unwrap_err();
7815
7816        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7817        assert!(error.to_string().contains("Git entry"), "{error}");
7818        assert!(nested.join(".git").exists());
7819    }
7820
7821    /// A reviewer that clones a reproduction into ignored build output used to
7822    /// lose the whole review. The state check refused every Git entry it had
7823    /// not put there, including the one Git reports as a nested checkout and
7824    /// the untracked snapshot already records by identity.
7825    #[test]
7826    fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
7827        let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
7828        exclude_paths(&repo, &["target/"]);
7829        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7830        let repro = path.join("target/pr918-repro");
7831        std::fs::create_dir_all(&repro).unwrap();
7832        test_git(&repro, &["init"]);
7833        std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
7834
7835        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7836            .unwrap();
7837
7838        assert!(repository_has_recoverable_work(&path, true).unwrap());
7839        assert!(repro.join(".git").exists());
7840    }
7841
7842    /// What sits under a nested checkout is that repository's business, so the
7843    /// scan stops at its directory rather than reading a checkout of its own
7844    /// as an entry the outer worktree has to account for.
7845    #[test]
7846    fn a_repository_inside_a_nested_checkout_is_left_alone() {
7847        let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
7848        exclude_paths(&repo, &["target/"]);
7849        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7850        let repro = path.join("target/repro");
7851        let vendored = repro.join("vendor/dep");
7852        std::fs::create_dir_all(&vendored).unwrap();
7853        test_git(&repro, &["init"]);
7854        test_git(&vendored, &["init"]);
7855
7856        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7857            .unwrap();
7858
7859        assert!(vendored.join(".git").exists());
7860    }
7861
7862    #[test]
7863    fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
7864        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
7865
7866        let state = ignored_untracked_state(repo.root()).unwrap();
7867
7868        let relative = path.strip_prefix(repo.root()).unwrap();
7869        assert!(
7870            state.files.contains_key(relative),
7871            "{:?}",
7872            state.files.keys().collect::<Vec<_>>()
7873        );
7874        assert!(state.is_ignored(relative));
7875    }
7876
7877    #[test]
7878    fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
7879        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
7880        let baseline = repo.worktree_baseline(repo.root()).unwrap();
7881        std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
7882        std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
7883
7884        repo.refuse_new_ignored_files(repo.root(), &baseline)
7885            .unwrap();
7886        repo.refuse_changed_existing_untracked(repo.root(), &baseline)
7887            .unwrap();
7888    }
7889
7890    #[test]
7891    fn deleting_a_resident_worktree_during_a_call_is_refused() {
7892        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
7893        let baseline = repo.worktree_baseline(repo.root()).unwrap();
7894        std::fs::remove_dir_all(&path).unwrap();
7895
7896        let error = repo
7897            .refuse_new_ignored_files(repo.root(), &baseline)
7898            .unwrap_err();
7899
7900        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7901        assert!(error.to_string().contains("review-932"), "{error}");
7902    }
7903
7904    #[test]
7905    fn a_nested_repository_record_is_read_as_a_plain_path() {
7906        let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
7907        assert_eq!(Path::new("vendor/checkout"), path);
7908        assert!(nested);
7909
7910        let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
7911        assert_eq!(Path::new("vendor/notes.txt"), path);
7912        assert!(!nested);
7913
7914        assert!(untracked_record(b"/", "untracked").is_err());
7915    }
7916
7917    #[cfg(unix)]
7918    #[test]
7919    fn a_non_utf8_git_path_is_preserved_without_loss() {
7920        use std::os::unix::ffi::OsStrExt;
7921
7922        let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
7923
7924        assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
7925    }
7926
7927    #[test]
7928    fn guarded_merge_pins_the_reviewed_head() {
7929        let args = merge_pr_args("36", Some("abc123"), true);
7930        assert_eq!(
7931            vec![
7932                "pr",
7933                "merge",
7934                "36",
7935                "--squash",
7936                "--delete-branch",
7937                "--match-head-commit",
7938                "abc123"
7939            ],
7940            args
7941        );
7942    }
7943
7944    #[test]
7945    fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
7946        let pr = PrRef {
7947            number: 7,
7948            url: "https://example.test/pull/7".into(),
7949            title: "part one".into(),
7950        };
7951        let result = reconcile_pr_creation(
7952            "split-34-1",
7953            Err(crate::error::SparError::new("connection lost")),
7954            Ok(Some(pr)),
7955        )
7956        .unwrap();
7957        assert_eq!(7, result.number);
7958    }
7959
7960    #[test]
7961    fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
7962        let error = reconcile_pr_creation(
7963            "split-34-1",
7964            Err(crate::error::SparError::new("permission denied")),
7965            Ok(None),
7966        )
7967        .unwrap_err();
7968        assert!(error.to_string().contains("permission denied"), "{error}");
7969    }
7970
7971    #[test]
7972    fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
7973        let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
7974        assert!(pr_for_base(text, "split-34-2", "split-34-1")
7975            .unwrap()
7976            .is_none());
7977        let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
7978        assert_eq!(7, found.number);
7979    }
7980
7981    #[test]
7982    fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
7983        let result = reconcile_comment_post(
7984            34,
7985            "the summary",
7986            crate::error::SparError::new("connection lost"),
7987            Ok(vec![serde_json::json!({"body": "the summary"})]),
7988        );
7989        assert!(result.is_ok(), "{result:?}");
7990    }
7991
7992    #[test]
7993    fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
7994        let error = reconcile_comment_post(
7995            34,
7996            "the summary",
7997            crate::error::SparError::new("connection lost"),
7998            Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
7999        )
8000        .unwrap_err();
8001        assert_eq!("connection lost", error.to_string());
8002    }
8003
8004    #[test]
8005    fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
8006        let error = reconcile_comment_post(
8007            34,
8008            "the summary",
8009            crate::error::SparError::new("connection lost"),
8010            Err(crate::error::SparError::new("comments unavailable")),
8011        )
8012        .unwrap_err();
8013        assert!(
8014            error.to_string().contains("could not be verified"),
8015            "{error}"
8016        );
8017        assert!(
8018            error.to_string().contains("comments unavailable"),
8019            "{error}"
8020        );
8021        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8022        assert!(!error.worth_retrying());
8023    }
8024
8025    #[test]
8026    fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
8027        let result = reconcile_issue_edit(
8028            34,
8029            "wanted body",
8030            crate::error::SparError::new("connection lost"),
8031            Ok("wanted body".to_string()),
8032        );
8033        assert!(result.is_ok(), "{result:?}");
8034    }
8035
8036    #[test]
8037    fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
8038        let error = reconcile_issue_edit(
8039            34,
8040            "wanted body",
8041            crate::error::SparError::new("connection lost"),
8042            Err(crate::error::SparError::new("issue unavailable")),
8043        )
8044        .unwrap_err();
8045        assert!(
8046            error.to_string().contains("could not be verified"),
8047            "{error}"
8048        );
8049        assert!(error.to_string().contains("issue unavailable"), "{error}");
8050        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8051        assert!(!error.worth_retrying());
8052    }
8053
8054    #[test]
8055    fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
8056        let found = ExistingIssue {
8057            number: 101,
8058            url: "https://example.test/issues/101".into(),
8059            title: "child".into(),
8060            body: "body".into(),
8061            open: true,
8062        };
8063        let url = reconcile_issue_creation(
8064            "child",
8065            Err(crate::error::SparError::new("connection lost")),
8066            Ok(Some(found)),
8067        )
8068        .unwrap();
8069        assert_eq!("https://example.test/issues/101", url);
8070    }
8071
8072    #[test]
8073    fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
8074        let error = reconcile_issue_creation(
8075            "child",
8076            Err(crate::error::SparError::new("permission denied")),
8077            Ok(None),
8078        )
8079        .unwrap_err();
8080        assert!(error.to_string().contains("permission denied"), "{error}");
8081    }
8082
8083    #[test]
8084    fn an_unverifiable_issue_creation_is_marked_uncertain() {
8085        let error = reconcile_issue_creation(
8086            "child",
8087            Err(crate::error::SparError::new("connection lost")),
8088            Err(crate::error::SparError::new("issues unavailable")),
8089        )
8090        .unwrap_err();
8091        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8092        assert!(!error.worth_retrying());
8093    }
8094
8095    #[test]
8096    fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
8097        let result = reconcile_failed_split_push(
8098            "split-34-1",
8099            crate::error::SparError::new("connection lost"),
8100            Ok("abc123\n".into()),
8101            Ok("abc123\trefs/heads/split-34-1\n".into()),
8102        );
8103        assert!(result.is_ok(), "{result:?}");
8104    }
8105
8106    #[test]
8107    fn a_split_push_collision_is_definite_and_never_overwrites() {
8108        let error = reconcile_failed_split_push(
8109            "split-34-1",
8110            crate::error::SparError::new("lease rejected"),
8111            Ok("abc123\n".into()),
8112            Ok("def456\trefs/heads/split-34-1\n".into()),
8113        )
8114        .unwrap_err();
8115        assert!(!error.retain_worktree());
8116        assert!(
8117            error.to_string().contains("Nothing was overwritten"),
8118            "{error}"
8119        );
8120    }
8121
8122    #[test]
8123    fn an_unreadable_split_push_result_keeps_the_worktree() {
8124        let error = reconcile_failed_split_push(
8125            "split-34-1",
8126            crate::error::SparError::new("connection lost"),
8127            Ok("abc123\n".into()),
8128            Err(crate::error::SparError::new("origin unavailable")),
8129        )
8130        .unwrap_err();
8131        assert!(error.retain_worktree());
8132        assert!(error.to_string().contains("could not confirm"), "{error}");
8133    }
8134
8135    /// Follow-up deduplication compares a title it computed against the title
8136    /// GitHub stored. If those two transforms can disagree, the check never
8137    /// matches and every review round files another copy of the same issue.
8138    #[test]
8139    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
8140        let repo = repo_for_titles();
8141        for raw in [
8142            "Retry loop spins \u{2014} Retry-After parses to zero",
8143            "plain title",
8144            "  spread   over\nlines  ",
8145            "\u{1F916} Generated with something",
8146            &format!("a \u{2014} {}", "very long title ".repeat(20)),
8147            &"x".repeat(300),
8148            &format!("{} \u{2014} end", "y".repeat(88)),
8149            // Exactly the budget, with two spaceless dashes. The scrub turns
8150            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
8151            // character over budget per dash and a second pass clips again,
8152            // producing a different string. Scrub-then-clip cannot.
8153            &{
8154                let tail = "a\u{2014}b c\u{2014}d";
8155                let pad = Style::default().max_title_chars - tail.chars().count();
8156                format!("{}{tail}", "w".repeat(pad))
8157            },
8158        ] {
8159            let once = repo.clean_title(raw).unwrap();
8160            let twice = repo.clean_title(&once).unwrap();
8161            assert_eq!(once, twice, "not idempotent for {raw:?}");
8162            assert!(
8163                once.chars().count() <= repo.style.max_title_chars,
8164                "over budget: {once:?}"
8165            );
8166            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
8167        }
8168    }
8169
8170    #[test]
8171    fn a_title_with_an_em_dash_survives_as_readable_text() {
8172        let repo = repo_for_titles();
8173        assert_eq!(
8174            "Retry loop spins, Retry-After parses to zero",
8175            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
8176                .unwrap()
8177        );
8178    }
8179
8180    #[test]
8181    fn sh_quote_survives_a_quote() {
8182        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
8183    }
8184
8185    #[test]
8186    fn sh_quote_wraps_a_space() {
8187        assert_eq!(
8188            "'/Applications/My App/spar'",
8189            sh_quote("/Applications/My App/spar")
8190        );
8191    }
8192
8193    #[test]
8194    fn finished_states_are_recognised_case_insensitively() {
8195        assert!(is_finished("MERGED"));
8196        assert!(is_finished("closed"));
8197        assert!(!is_finished("OPEN"));
8198        assert!(!is_finished(""));
8199    }
8200
8201    fn state() -> PersistedState {
8202        PersistedState {
8203            version: 1,
8204            checkpoint: 0,
8205            round: 4,
8206            next_actor: "codex".into(),
8207            status: Status::Pending,
8208            pr_head: "abc123".into(),
8209            ledger: Ledger::new(),
8210            filed: vec![],
8211            open_findings: vec![Finding {
8212                severity: Severity::Blocking,
8213                title: "Unchecked error".into(),
8214                detail: "the failure is discarded".into(),
8215                file: "src/a.rs:12".into(),
8216                ..Finding::default()
8217            }],
8218            disputes: vec![Dispute {
8219                title: "Retry limit".into(),
8220                file: "src/net.rs".into(),
8221                reasoning: "the caller already bounds it".into(),
8222            }],
8223            noted: vec![Finding {
8224                severity: Severity::NonBlocking,
8225                title: "Timeout is fixed".into(),
8226                file: "src/config.rs".into(),
8227                ..Finding::default()
8228            }],
8229        }
8230    }
8231
8232    #[test]
8233    fn a_state_comment_round_trips() {
8234        let body = format!(
8235            "{STATE_MARKER}\n{}\n-->",
8236            serde_json::to_string(&state()).unwrap()
8237        );
8238        let back = parse_state_comment(&body).unwrap();
8239        assert_eq!(4, back.round);
8240        assert_eq!("codex", back.next_actor);
8241        assert_eq!("abc123", back.pr_head);
8242        assert_eq!("Unchecked error", back.open_findings[0].title);
8243        assert_eq!("src/net.rs", back.disputes[0].file);
8244        assert_eq!("Timeout is fixed", back.noted[0].title);
8245    }
8246
8247    #[test]
8248    fn old_state_without_new_lists_still_parses() {
8249        let body = format!(
8250            "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
8251             \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
8252        );
8253        let back = parse_state_comment(&body).expect("old state");
8254        assert!(back.open_findings.is_empty());
8255        assert!(back.disputes.is_empty());
8256        assert!(back.noted.is_empty());
8257        assert!(back.pr_head.is_empty());
8258        assert_eq!(0, back.checkpoint);
8259    }
8260
8261    #[test]
8262    fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
8263        let mut local = state();
8264        local.pr_head = "old".into();
8265        local.round = 9;
8266        let mut remote = state();
8267        remote.pr_head = "current".into();
8268        remote.round = 4;
8269
8270        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
8271        assert_eq!("current", chosen.pr_head);
8272        assert_eq!(4, chosen.round);
8273    }
8274
8275    #[test]
8276    fn checkpoint_order_breaks_same_round_ties() {
8277        let mut local = state();
8278        local.pr_head = "current".into();
8279        local.round = 4;
8280        local.checkpoint = 8;
8281        let mut remote = local.clone();
8282        remote.checkpoint = 7;
8283        remote.open_findings.clear();
8284
8285        let chosen = choose_state_for_head(vec![local], "current").unwrap();
8286        assert_eq!(8, chosen.checkpoint);
8287
8288        let mut local = state();
8289        local.pr_head = "current".into();
8290        local.round = 4;
8291        local.checkpoint = 8;
8292        let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
8293        assert_eq!(8, chosen.checkpoint);
8294    }
8295
8296    #[test]
8297    fn legacy_same_round_tie_keeps_the_local_checkpoint() {
8298        let mut local = state();
8299        local.pr_head = "current".into();
8300        local.round = 4;
8301        local.open_findings.push(Finding {
8302            title: "local checkpoint".into(),
8303            ..Finding::default()
8304        });
8305        let mut remote = state();
8306        remote.pr_head = "current".into();
8307        remote.round = 4;
8308
8309        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
8310        assert_eq!(
8311            "local checkpoint",
8312            chosen.open_findings.last().unwrap().title
8313        );
8314    }
8315
8316    /// It must render as nothing, so PRs are not littered with machine state.
8317    #[test]
8318    fn the_state_block_is_an_html_comment() {
8319        let body = format!(
8320            "{STATE_MARKER}\n{}\n-->",
8321            serde_json::to_string(&state()).unwrap()
8322        );
8323        assert!(body.starts_with("<!--"));
8324        assert!(body.trim_end().ends_with("-->"));
8325        assert!(!body[..body.find('{').unwrap()].contains("-->"));
8326    }
8327
8328    #[test]
8329    fn an_unrelated_json_block_is_not_state() {
8330        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
8331    }
8332
8333    /// Patching and deleting a comment is not something to do on a substring.
8334    ///
8335    /// A maintainer who notices the block and asks about it writes a comment
8336    /// containing `spar:state`, and the loose filter could not tell theirs from
8337    /// spar's. The next `persist` replaced their words with JSON and
8338    /// `spar clean --pr-state` later deleted it.
8339    #[test]
8340    fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
8341        let block = format!(
8342            "{STATE_MARKER}\n{}\n-->",
8343            serde_json::to_string(&state()).unwrap()
8344        );
8345        assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
8346        assert!(
8347            is_state_comment(&block, "Spar-Bot", "spar-bot"),
8348            "a login differs only in case"
8349        );
8350
8351        assert!(
8352            !is_state_comment(
8353                "why is there a `<!-- spar:state` block on this PR?",
8354                "maintainer",
8355                "spar-bot"
8356            ),
8357            "a question about the block was treated as the block"
8358        );
8359        assert!(
8360            !is_state_comment(&block, "maintainer", "spar-bot"),
8361            "somebody else's comment is not spar's to rewrite"
8362        );
8363        assert!(
8364            !is_state_comment(
8365                &format!("here is what spar writes:\n\n{block}"),
8366                "spar-bot",
8367                "spar-bot"
8368            ),
8369            "the marker has to open the body, not appear in it"
8370        );
8371        assert!(
8372            !is_state_comment(
8373                &format!("{STATE_MARKER}\n{{not json\n-->"),
8374                "spar-bot",
8375                "spar-bot"
8376            ),
8377            "a block that does not parse is not state"
8378        );
8379        assert!(
8380            !is_state_comment(&block, "spar-bot", ""),
8381            "an unknown viewer proves nothing about who wrote it"
8382        );
8383    }
8384
8385    #[test]
8386    fn a_malformed_state_comment_is_none_not_a_panic() {
8387        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
8388    }
8389
8390    #[test]
8391    fn atomic_write_leaves_no_temp_file() {
8392        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
8393        let _ = std::fs::remove_dir_all(&dir);
8394        let path = dir.join("state").join("pr-7.json");
8395        write_json_atomic(&path, &state()).unwrap();
8396        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
8397            .unwrap()
8398            .flatten()
8399            .filter_map(|e| e.file_name().to_str().map(str::to_string))
8400            .collect();
8401        assert_eq!(vec!["pr-7.json".to_string()], files);
8402        let _ = std::fs::remove_dir_all(&dir);
8403    }
8404
8405    #[test]
8406    fn atomic_write_overwrites_rather_than_accumulating() {
8407        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
8408        let _ = std::fs::remove_dir_all(&dir);
8409        let path = dir.join("pr-7.json");
8410        for round in 1..4 {
8411            let mut s = state();
8412            s.round = round;
8413            write_json_atomic(&path, &s).unwrap();
8414        }
8415        let back: PersistedState =
8416            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
8417        assert_eq!(3, back.round);
8418        let _ = std::fs::remove_dir_all(&dir);
8419    }
8420
8421    #[test]
8422    fn style_from_env_defaults_to_enforcing() {
8423        std::env::remove_var("SPAR_BAN_EM_DASH");
8424        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
8425        let style = style_from_env();
8426        assert!(style.ban_em_dash && style.ban_ai_attribution);
8427        assert!(
8428            !style.terse,
8429            "the commit filter must not truncate a commit message"
8430        );
8431    }
8432}
8433
8434#[cfg(test)]
8435mod comment_page_tests {
8436    use super::*;
8437
8438    #[test]
8439    fn a_single_merged_array_is_read() {
8440        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
8441        assert_eq!(2, pages.len());
8442        assert_eq!(Some(2), pages[1]["id"].as_i64());
8443    }
8444
8445    #[test]
8446    fn concatenated_pages_from_an_older_gh_are_read_too() {
8447        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
8448        assert_eq!(2, pages.len());
8449    }
8450
8451    /// A comment body containing a bracket pair used to split the payload into
8452    /// two invalid halves, so no state comment was found and a resume silently
8453    /// started from round one.
8454    #[test]
8455    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
8456        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
8457        let pages = parse_comment_pages(text);
8458        assert_eq!(2, pages.len(), "{pages:?}");
8459        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
8460    }
8461
8462    #[test]
8463    fn empty_output_is_no_comments_not_a_panic() {
8464        assert!(parse_comment_pages("").is_empty());
8465        assert!(parse_comment_pages("   ").is_empty());
8466        assert!(parse_comment_pages("[]").is_empty());
8467    }
8468
8469    #[test]
8470    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
8471        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
8472    }
8473
8474    #[test]
8475    fn a_write_postcheck_rejects_truncated_comment_pages() {
8476        let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
8477        assert!(
8478            error.to_string().contains("unexpected comment pages"),
8479            "{error}"
8480        );
8481    }
8482
8483    #[test]
8484    fn a_write_postcheck_rejects_empty_or_non_array_output() {
8485        assert!(try_parse_comment_pages("").is_err());
8486        assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
8487        assert!(try_parse_comment_pages("[]").is_ok());
8488    }
8489
8490    #[test]
8491    fn state_is_found_in_the_last_matching_comment() {
8492        let payload = |round: u32| {
8493            format!(
8494                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
8495            )
8496        };
8497        let text = serde_json::to_string(&serde_json::json!([
8498            {"id": 1, "body": payload(1)},
8499            {"id": 2, "body": "looks good to me"},
8500            {"id": 3, "body": payload(5)},
8501        ]))
8502        .unwrap();
8503        let pages = parse_comment_pages(&text);
8504        let last = pages
8505            .iter()
8506            .rev()
8507            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
8508            .unwrap();
8509        assert_eq!(5, last.round);
8510    }
8511}
8512
8513#[cfg(test)]
8514mod linked_pr_tests {
8515    use super::*;
8516
8517    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
8518    /// It carries an id and a whole repository object per linked issue, and a
8519    /// parser that chokes on those reports "no pull request", which is the one
8520    /// answer that makes spar implement over the top of somebody's work.
8521    const REAL_PAYLOAD: &str = r#"[
8522      {"number":14252,"title":"fix: reject leading-dash branch names",
8523       "url":"https://github.com/cli/cli/pull/14252",
8524       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
8525         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
8526         "url":"https://github.com/cli/cli/issues/14238"}]},
8527      {"number":14217,"title":"another change",
8528       "url":"https://github.com/cli/cli/pull/14217",
8529       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
8530         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
8531         "url":"https://github.com/cli/cli/issues/9761"}]},
8532      {"number":14200,"title":"unlinked work",
8533       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
8534    ]"#;
8535
8536    #[test]
8537    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
8538        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
8539        assert_eq!(14252, pr.number);
8540        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
8541    }
8542
8543    #[test]
8544    fn the_right_pr_is_picked_out_of_several() {
8545        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
8546    }
8547
8548    #[test]
8549    fn an_issue_nobody_is_working_on_finds_nothing() {
8550        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
8551    }
8552
8553    #[test]
8554    fn an_unlinked_pr_is_never_matched() {
8555        // 14200 closes nothing, so no issue number should ever return it.
8556        for issue in [14200, 0, 1] {
8557            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
8558                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
8559            }
8560        }
8561    }
8562
8563    #[test]
8564    fn empty_or_broken_output_is_none_rather_than_a_panic() {
8565        assert!(find_linked_pr("", 1).is_none());
8566        assert!(find_linked_pr("[]", 1).is_none());
8567        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
8568        assert!(find_linked_pr("[{\"number\":", 1).is_none());
8569    }
8570
8571    /// "gh could not answer" and "nobody is working on it" are different
8572    /// answers, and only one of them means implement from scratch.
8573    ///
8574    /// The parse used to fold both into `None`, so an authentication failure or
8575    /// a rate limit read as an empty list and spar opened a second pull request
8576    /// for an issue somebody already had work open on.
8577    #[test]
8578    fn an_unreadable_list_is_not_an_empty_list() {
8579        assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
8580        assert!(linked_pr_rows("[{\"number\":").is_none());
8581        assert!(linked_pr_rows("").is_none());
8582
8583        let empty = linked_pr_rows("[]").expect("an empty list is an answer");
8584        assert!(empty.is_empty());
8585        assert!(pick_linked_pr(&empty, 14238).is_none());
8586
8587        let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
8588        assert_eq!(3, listed.len());
8589        assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
8590    }
8591
8592    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
8593    #[test]
8594    fn pr_view_reads_the_cross_repository_flag() {
8595        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
8596                       "baseRefName":"main","state":"OPEN",
8597                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
8598        let pr: PrView = serde_json::from_str(json).unwrap();
8599        assert!(pr.is_cross_repository);
8600        assert!(pr.is_open());
8601
8602        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
8603        assert!(
8604            !serde_json::from_str::<PrView>(&same_repo)
8605                .unwrap()
8606                .is_cross_repository
8607        );
8608    }
8609}
8610
8611#[cfg(test)]
8612mod min_number_tests {
8613    /// The floor is applied before the cap, which is the order that matters.
8614    /// spar takes the *lowest* numbered open items, so a repository with a tail
8615    /// of old issues would otherwise spend its whole run in the tail: the cap
8616    /// would be filled by the oldest items and the floor would never be
8617    /// reached. Filtering first is what makes the setting do anything.
8618    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
8619        let mut numbers: Vec<i64> = open.to_vec();
8620        numbers.sort_unstable();
8621        if min_number > 0 {
8622            numbers.retain(|n| *n >= min_number);
8623        }
8624        numbers.truncate(limit);
8625        numbers
8626    }
8627
8628    #[test]
8629    fn the_floor_is_applied_before_the_cap_not_after() {
8630        let open = [12, 13, 14, 480, 481, 482];
8631        assert_eq!(vec![480, 481], pick(&open, 2, 480));
8632        // Capping first would have returned the two oldest and then filtered
8633        // them all away, leaving nothing.
8634        assert!(!pick(&open, 2, 480).is_empty());
8635    }
8636
8637    #[test]
8638    fn no_floor_keeps_the_old_behaviour() {
8639        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
8640    }
8641
8642    #[test]
8643    fn the_floor_is_inclusive() {
8644        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
8645    }
8646
8647    #[test]
8648    fn a_floor_above_everything_open_yields_nothing() {
8649        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
8650    }
8651}