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