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