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/// What is read of one issue that is about to reach a prompt.
29///
30/// The comments come in the same call. The body is what somebody wrote once,
31/// and the thread is where they narrowed it, widened it, or found the real
32/// defect under the one they filed. Linking to the thread instead left that to
33/// an agent that had to guess the body was out of date, and codex, which has no
34/// network, could not follow the link at all.
35const ISSUE_FIELDS: &str = "number,title,body,labels,state,stateReason,url,author,comments";
36
37/// gh returns newest first, so its `--limit` cannot be used to take the lowest
38/// numbered items: it would slice the newest N and then sorting that slice
39/// silently drops the older ones. Fetch a generous page, sort, then truncate.
40pub const FETCH_CEILING: usize = 500;
41
42/// An unclosed HTML comment on purpose. The payload is written after it and
43/// terminated with `-->`, so GitHub renders the whole block as nothing.
44pub const STATE_MARKER: &str = "<!-- spar:state";
45
46/// The signature on a comment spar composed, invisible when rendered.
47///
48/// spar posts as whoever ran it, so a comment from the viewer proves nothing
49/// about who wrote it. Without this, spar's own outcome and round comments
50/// counted as the viewer having answered, and `checkin` skipped a person's
51/// comment as "replied to since" because a bookkeeping comment happened to be
52/// newer than it.
53pub const COMMENT_MARKER: &str = "<!-- spar:comment -->";
54
55/// An entry boundary in the local follow-up note, on the same principle as
56/// `STATE_MARKER` and rendered as nothing for the same reason.
57///
58/// A follow-up's own sections are written as `## Problem` and friends, at the
59/// same heading level as the entry's title, so the file's shape does not say
60/// which of two `## ` lines starts an entry. This does. Files written before it
61/// existed are still read, by the heuristic in `followups::parse`.
62pub const FOLLOWUP_MARKER: &str = "<!-- spar:followup -->";
63
64const WORKTREE_DIR: &str = ".spar-worktrees";
65const STATE_DIR: &str = ".spar";
66
67/// Files SPAR writes into a worktree itself, which no agent ever authors.
68pub(crate) const RECOVERY_MARKER_PREFIX: &str = ".spar-recovery-needed-";
69pub(crate) const EDITED_GIT_MARKER: &str = ".spar-edited-git-marker";
70
71/// What SPAR is allowed to throw away when it decides a checkout can go.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub(crate) enum Disposable {
74    /// Recognized build output, and nothing else. Somebody may have put the
75    /// rest there and want it back.
76    BuildOutput,
77    /// Every ignored file, and SPAR's own markers. Only rebuilding a review
78    /// checkout qualifies, and only because the next statement replaces it with
79    /// a fresh checkout of the same ref: what is there came from an earlier
80    /// `spar review` of the same pull request, and `git worktree add` would
81    /// write over it regardless. Refusing instead left the command unusable on
82    /// any project whose build writes outside `target/`, since a single
83    /// lockfile beside a generator's manifest blocked every later run.
84    ReviewOutput,
85}
86
87/// Whether `path` is one of the files SPAR leaves in a worktree root.
88///
89/// A marker is evidence for a person, not something a call did to the tree, and
90/// two reviewers share one checkout: the marker written when the first one's
91/// answer was refused is otherwise a brand new untracked file in the snapshot
92/// the second one is compared against, so one refusal took the other review
93/// down with it.
94pub(crate) fn is_spar_marker(path: &Path) -> bool {
95    let mut components = path.components();
96    let Some(std::path::Component::Normal(name)) = components.next() else {
97        return false;
98    };
99    if components.next().is_some() {
100        return false;
101    }
102    let Some(name) = name.to_str() else {
103        return false;
104    };
105    name == EDITED_GIT_MARKER || name.starts_with(RECOVERY_MARKER_PREFIX)
106}
107
108/// Whether `path` is somewhere SPAR writes in the repository it is driving.
109///
110/// The markers are one part of it. The other is the run state under `.spar/`,
111/// which SPAR rewrites while a call it started is still running: recording the
112/// branches it created during a parallel triage is enough to make both agents
113/// look like they changed the tree they were told only to read. SPAR's own
114/// bookkeeping is not evidence about an agent, so it never enters the snapshot
115/// a call is compared against.
116///
117/// `.spar-worktrees/` is deliberately not here. A resident worktree enters the
118/// snapshot as one ignored entry so that another run's churn inside it does not
119/// disturb this baseline, while deleting the whole checkout still does.
120pub(crate) fn is_spar_own_path(path: &Path) -> bool {
121    if is_spar_marker(path) {
122        return true;
123    }
124    let Some(std::path::Component::Normal(name)) = path.components().next() else {
125        return false;
126    };
127    name == OsStr::new(STATE_DIR)
128}
129
130/// How many names one part of a split may be tried on before giving up. High
131/// enough that nobody reaches it by splitting the same pull request again, low
132/// enough that a repository where every name is taken says so rather than
133/// looping.
134const SPLIT_SLOTS: u32 = 20;
135
136#[derive(Debug, Clone)]
137pub struct SplitPushError {
138    message: String,
139    retain_worktree: bool,
140}
141
142impl SplitPushError {
143    pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
144        Self {
145            message: message.into(),
146            retain_worktree,
147        }
148    }
149
150    pub fn retain_worktree(&self) -> bool {
151        self.retain_worktree
152    }
153}
154
155impl std::fmt::Display for SplitPushError {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        f.write_str(&self.message)
158    }
159}
160
161impl std::error::Error for SplitPushError {}
162
163/// The branch and worktree name for one part, on its `attempt`th name.
164///
165/// The unsuffixed name first, so the ordinary case reads as `split-12-1` and
166/// only a repeat split carries a suffix.
167fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
168    match attempt {
169        1 => format!("split-{parent}-{index}"),
170        n => format!("split-{parent}-{index}-{n}"),
171    }
172}
173
174#[derive(Debug)]
175pub struct Repo {
176    root: PathBuf,
177    pub style: Style,
178    pub branch_prefix: String,
179    pub state_store: StateStore,
180    pub followups: Followups,
181    pub drafts: Drafts,
182    /// The login `gh` is authenticated as, asked at most once.
183    ///
184    /// `OnceLock` rather than `OnceCell` because `&Repo` crosses a
185    /// `std::thread::scope` whenever both agents are asked at the same time,
186    /// and only `OnceLock` is `Sync`.
187    viewer: OnceLock<String>,
188    /// Highest persisted checkpoint observed for each pull request.
189    ///
190    /// Kept in memory so a transient state read cannot reset the sequence
191    /// after a resume already loaded a newer checkpoint.
192    checkpoints: Mutex<BTreeMap<i64, u64>>,
193    writes: WriteStats,
194}
195
196#[derive(Debug, Default)]
197struct WriteStats {
198    attempted: AtomicUsize,
199    failed: AtomicUsize,
200}
201
202#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
203pub(crate) struct WriteSummary {
204    pub(crate) attempted: usize,
205    pub(crate) failed: usize,
206}
207
208impl WriteSummary {
209    pub(crate) fn succeeded(self) -> usize {
210        self.attempted.saturating_sub(self.failed)
211    }
212}
213
214/// Ignored, untracked paths present before an editing call starts.
215///
216/// Existing build artifacts are deliberately part of the baseline. Callers can
217/// therefore distinguish them from an ignored file the editing call created
218/// and avoid deleting the latter as if no work had happened.
219#[derive(Debug, Clone)]
220pub(crate) struct WorktreeBaseline {
221    attributes: AttributeState,
222    ignored_untracked: IgnoredState,
223    git_state: GitState,
224}
225
226/// The recorded Git state of a worktree that must remain read only.
227///
228/// Read-only agent calls are still ordinary processes. Capturing the complete
229/// working state lets callers refuse to publish their answer or delete the
230/// checkout if a call writes despite its instructions.
231#[derive(Debug, Clone)]
232pub(crate) struct WorktreeCheckpoint {
233    path: PathBuf,
234    attributes: AttributeState,
235    git_state: GitState,
236    ignored_untracked: IgnoredState,
237}
238
239#[derive(Debug, Clone, Default, PartialEq, Eq)]
240pub(crate) struct AttributeState {
241    files: BTreeMap<PathBuf, [u8; 32]>,
242}
243
244/// Exact paths and fingerprints for every untracked file, plus ignored paths.
245///
246/// Paths stay as operating-system strings so a non-UTF-8 filename cannot be
247/// merged with another path by lossy command-output conversion.
248#[derive(Debug, Clone, Default, PartialEq, Eq)]
249pub(crate) struct IgnoredState {
250    files: BTreeMap<PathBuf, UntrackedFile>,
251    ignored: BTreeSet<PathBuf>,
252}
253
254/// A bounded-cost fingerprint for an untracked filesystem entry.
255///
256/// Content hashing every ignored compiler artifact made each checkpoint read
257/// gigabytes. File identity, type, size, timestamps, and mode detect ordinary
258/// writes without rereading build output. Unix change time also changes when a
259/// writer restores the modification time.
260#[derive(Debug, Clone, PartialEq, Eq)]
261struct UntrackedFile {
262    kind: u8,
263    len: u64,
264    modified: Option<std::time::SystemTime>,
265    created: Option<std::time::SystemTime>,
266    readonly: bool,
267    symlink_target: Option<Vec<u8>>,
268    #[cfg(unix)]
269    device: u64,
270    #[cfg(unix)]
271    inode: u64,
272    #[cfg(unix)]
273    mode: u32,
274    #[cfg(unix)]
275    change_seconds: i64,
276    #[cfg(unix)]
277    change_nanoseconds: i64,
278}
279
280#[derive(Debug, Clone, Default, PartialEq, Eq)]
281pub(crate) struct GitState {
282    repositories: BTreeMap<PathBuf, RepositoryState>,
283}
284
285#[derive(Debug, Clone, PartialEq, Eq)]
286struct RepositoryState {
287    head: String,
288    unsafe_index_flags: Vec<u8>,
289    tracked: BTreeMap<PathBuf, TrackedEntry>,
290    gitlinks: BTreeMap<PathBuf, String>,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
294struct TrackedEntry {
295    index_mode: String,
296    index_oid: String,
297    worktree: Option<WorktreeFile>,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
301struct WorktreeFile {
302    mode: String,
303    #[cfg(unix)]
304    permissions: u32,
305    raw_oid: String,
306    fingerprint: [u8; 32],
307    content: [u8; 32],
308}
309
310struct Gitlink {
311    path: PathBuf,
312    oid: String,
313}
314
315struct IndexEntry {
316    path: PathBuf,
317    mode: String,
318    oid: String,
319}
320
321impl IgnoredState {
322    fn is_ignored(&self, path: &Path) -> bool {
323        self.ignored.contains(path)
324    }
325
326    fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
327        let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
328        paths.extend(after.files.keys().cloned());
329        paths
330            .into_iter()
331            .filter(|path| {
332                self.files.get(path) != after.files.get(path)
333                    || self.is_ignored(path) != after.is_ignored(path)
334            })
335            .collect()
336    }
337
338    fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
339        self.files
340            .iter()
341            .filter(|(path, state)| after.files.get(*path) != Some(*state))
342            .map(|(path, _)| path.clone())
343            .collect()
344    }
345
346    fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
347        after
348            .files
349            .keys()
350            .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
351            .cloned()
352            .collect()
353    }
354
355    /// Whether anything moved here that is not recognized build or cache
356    /// output.
357    ///
358    /// A read-only call is held to its word by comparing the worktree before
359    /// and after. Verifying a finding usually means building the project and
360    /// running its tests, which rewrites that output, and holding a reviewer to
361    /// a byte-identical `dist/` threw away the review it had just done. What
362    /// the call was asked to judge is the tracked tree, and that is compared as
363    /// strictly as ever, along with every other untracked file.
364    pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
365        let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
366        paths.extend(after.files.keys());
367        paths.into_iter().any(|path| {
368            if self.files.get(path) == after.files.get(path)
369                && self.is_ignored(path) == after.is_ignored(path)
370            {
371                return false;
372            }
373            !self.generated_move(after, path)
374        })
375    }
376
377    /// What a read-only call left behind, split by whether it condemns the
378    /// answer.
379    ///
380    /// `changed_beyond_generated` answers this from a list of directory names,
381    /// which is a guess about where a build writes. The repository already
382    /// states where it writes, in its own ignore rules, and a review died over
383    /// the gap: a reviewer ran the project's binding generator to check a
384    /// finding, that wrote a lockfile beside its own manifest rather than under
385    /// `target/`, and both agents lost an hour of work to a path Git had been
386    /// told to disown.
387    ///
388    /// Forgiving only the creation of such a path was not enough, because the
389    /// call is not the only writer. A read-only call runs for minutes in the
390    /// primary checkout, which is a live desk: Finder rewrites `.DS_Store`, an
391    /// editor rewrites its own state, and a triage that asks two agents at once
392    /// compares both of them against that same directory. One ambient write
393    /// discarded two good answers and ended the run with nothing scheduled. So
394    /// a path the project ignores is out of scope however it moved, and what
395    /// remains fatal is what the call was actually asked about: the tracked
396    /// tree, and any ordinary untracked file, because a reviewer was told to
397    /// keep its scratch out of the worktree.
398    ///
399    /// Nothing is silent. A tolerated path that is not recognized build output
400    /// is named by the caller, so an agent that rewrote a person's `.env` is
401    /// reported rather than hidden. That is the trade this makes: the answer of
402    /// a call that never commits is worth more than the guarantee, and the
403    /// warning carries what the guarantee used to.
404    pub(crate) fn read_only_leavings(&self, after: &Self) -> ReadOnlyLeavings {
405        let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
406        paths.extend(after.files.keys());
407        let mut leavings = ReadOnlyLeavings::default();
408        for path in paths {
409            if self.files.get(path) == after.files.get(path)
410                && self.is_ignored(path) == after.is_ignored(path)
411            {
412                continue;
413            }
414            if !self.ignored_file_move(after, path) {
415                leavings.condemning.push(path.clone());
416            } else if !is_generated_artifact(path) {
417                leavings.tolerated.push(path.clone());
418            }
419        }
420        leavings
421    }
422
423    /// Whether what happened at `path` between these two states is a build or
424    /// cache directory doing its job, rather than a change a person would miss.
425    ///
426    /// Removal counts, on either side. A compiler that keeps an incremental
427    /// cache does not only add and rewrite: it finalizes a new session
428    /// directory and deletes the one it superseded, lock file and all. A rule
429    /// that forgave creation and rewriting but not removal therefore passed the
430    /// first build in a fresh worktree and failed the second one, which is the
431    /// build a reviewer runs to check a finding. Two of these rules disagreeing
432    /// about the same directory is what cost the run: the read-only check
433    /// forgave the deleted session and the commit-side check called it
434    /// unrepresentable, so the work was already on a pull request when the run
435    /// ended. There is one rule now, and this is it.
436    fn generated_move(&self, after: &Self, path: &Path) -> bool {
437        is_generated_artifact(path) && self.disposable_move(after, path)
438    }
439
440    /// Successful edits and read-only calls share an ignore policy. Spar's
441    /// resident worktrees belong to other runs, not to this call's output.
442    /// Cleanup separately preserves ignored data, including dependency clones.
443    fn ignored_file_move(&self, after: &Self, path: &Path) -> bool {
444        if path.starts_with(WORKTREE_DIR)
445            && self.files.get(path).is_some_and(|file| file.kind == 3)
446            && self.files.get(path) != after.files.get(path)
447        {
448            return false;
449        }
450        self.disposable_move(after, path)
451    }
452
453    /// Whether neither state has anything at `path` worth keeping, so whatever
454    /// happened there took nothing with it.
455    fn disposable_move(&self, after: &Self, path: &Path) -> bool {
456        self.disposable(path) && after.disposable(path)
457    }
458
459    /// Whether this state has nothing at `path` worth keeping: either the path
460    /// is not there at all, or it is there as an ignored file.
461    fn disposable(&self, path: &Path) -> bool {
462        !self.files.contains_key(path) || self.is_ignored(path)
463    }
464}
465
466impl GitState {
467    /// What moved between two states, said so a person can act on it.
468    ///
469    /// The guards that compare these used to report only that the worktree had
470    /// changed, which left the one question worth answering, which file, to a
471    /// `git status` run by hand against a tree that had moved on since.
472    pub(crate) fn describe_difference(&self, after: &Self) -> Option<String> {
473        let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
474        prefixes.extend(after.repositories.keys());
475        let mut notes: Vec<String> = Vec::new();
476        let mut paths: Vec<PathBuf> = Vec::new();
477        for prefix in prefixes {
478            let label = describe_repository(prefix);
479            match (
480                self.repositories.get(prefix),
481                after.repositories.get(prefix),
482            ) {
483                (None, Some(_)) => notes.push(format!("a repository appeared at {label}")),
484                (Some(_), None) => notes.push(format!("the repository at {label} is gone")),
485                (Some(before), Some(current)) => {
486                    if before.head != current.head {
487                        notes.push(format!("HEAD moved in {label}"));
488                    }
489                    if before.unsafe_index_flags != current.unsafe_index_flags {
490                        notes.push(format!("index flags changed in {label}"));
491                    }
492                    paths.extend(
493                        differing_keys(&before.gitlinks, &current.gitlinks)
494                            .chain(differing_keys(&before.tracked, &current.tracked))
495                            .map(|path| prefix.join(path)),
496                    );
497                }
498                (None, None) => {}
499            }
500        }
501        if !paths.is_empty() {
502            paths.sort();
503            paths.dedup();
504            notes.push(format!("tracked file(s) changed: {}", list_paths(&paths)));
505        }
506        (!notes.is_empty()).then(|| notes.join("; "))
507    }
508
509    /// The tracked half of what a read-only call left behind, with a checkout
510    /// that moved under the call reported rather than held against it.
511    pub(crate) fn read_only_difference(&self, after: &Self, cwd: &Path) -> Option<String> {
512        let difference = self.describe_difference(after)?;
513        let Some(moved) = self.moved_wholesale(after) else {
514            return Some(difference);
515        };
516        // Once, however many calls were reading when it happened: two triage
517        // agents share the checkout and would otherwise both report the move.
518        if said_once("moved", cwd, &moved) {
519            logwarn!(
520                "the checkout at {} moved while a read-only call was reading it: {moved}. The \
521                 answer was kept, and it describes the tree as it stood before the move.",
522                cwd.display()
523            );
524        }
525        None
526    }
527
528    /// Whether every difference between these two states is the checkout moving
529    /// as a whole rather than the call writing into it.
530    ///
531    /// The primary checkout is a live desk and a read-only call reads it for
532    /// minutes. A person or their editor pulls, the branch fast forwards, and
533    /// every file that arrived with it now differs from the snapshot taken
534    /// before the call. Holding the call to that snapshot threw away two good
535    /// triage answers and ended a run with nothing scheduled, over a change the
536    /// call had no part in.
537    ///
538    /// The index tells the two apart. A call that wrote leaves a path
539    /// disagreeing with the index, staged or unstaged, and leaves HEAD where it
540    /// was. A checkout that moved rewrote the index and the worktree together,
541    /// so every path it touched agrees with the index, and it moved HEAD to say
542    /// so. Anything else, a tracked file that changed while HEAD stood still
543    /// most of all, is the call's to answer for.
544    fn moved_wholesale(&self, after: &Self) -> Option<String> {
545        let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
546        prefixes.extend(after.repositories.keys());
547        let mut notes: Vec<String> = Vec::new();
548        for prefix in prefixes {
549            // A repository that appeared or vanished is not a checkout moving.
550            let before = self.repositories.get(prefix)?;
551            let current = after.repositories.get(prefix)?;
552            if before.unsafe_index_flags != current.unsafe_index_flags
553                || before.gitlinks != current.gitlinks
554            {
555                return None;
556            }
557            let moved: Vec<&PathBuf> = differing_keys(&before.tracked, &current.tracked).collect();
558            if before.head == current.head {
559                if moved.is_empty() {
560                    continue;
561                }
562                return None;
563            }
564            if !moved.iter().all(|path| {
565                checked_out(before.tracked.get(*path)) && checked_out(current.tracked.get(*path))
566            }) {
567                return None;
568            }
569            let label = describe_repository(prefix);
570            notes.push(match moved.len() {
571                0 => format!("HEAD moved in {label}"),
572                count => format!("HEAD moved in {label}, carrying {count} tracked file(s)"),
573            });
574        }
575        (!notes.is_empty()).then(|| notes.join("; "))
576    }
577}
578
579/// Whether a tracked path holds exactly what the index says it holds, so
580/// nothing there is staged or left unstaged.
581///
582/// A path the index no longer lists counts as checked out: only an index
583/// operation drops one, and a file left in the worktree after that is an
584/// ordinary untracked file, which the untracked half of the same check has.
585fn checked_out(entry: Option<&TrackedEntry>) -> bool {
586    entry.is_none_or(|entry| {
587        entry
588            .worktree
589            .as_ref()
590            .is_some_and(|file| file.raw_oid == entry.index_oid && file.mode == entry.index_mode)
591    })
592}
593
594/// Say what a call left among paths the project ignores, once per run for a
595/// given set of them.
596///
597/// The project's own ignore rules put these outside what the call was asked
598/// about, so this is a note about the desk the call ran on rather than a
599/// finding about the call. Said as a warning on every call, it became the
600/// loudest thing in the log of every run in a checkout with an editor open in
601/// it: `.DS_Store` twice per triage, and again for each review. It still names
602/// the paths, so an agent that rewrote a person's `.env` is reported rather
603/// than hidden.
604pub(crate) fn note_ignored_leavings(cwd: &Path, what: &str, paths: &[PathBuf]) {
605    if paths.is_empty() {
606        return;
607    }
608    let named = list_paths(paths);
609    if !said_once("ignored", cwd, &named) {
610        return;
611    }
612    logdim!(
613        "a read-only {what} left {} ignored file(s) changed in {}: {named}. It was kept; the \
614         project ignores these paths.",
615        paths.len(),
616        cwd.display()
617    );
618}
619
620/// Whether this run has yet to say this about this directory.
621///
622/// A run reads the same checkout dozens of times, and the ambient facts about
623/// it, an editor's own files moving and a branch advancing under the reader,
624/// are true for every one of those calls. Said once they are context; said
625/// each time they are the log.
626fn said_once(topic: &str, cwd: &Path, detail: &str) -> bool {
627    static SAID: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
628    SAID.lock()
629        .unwrap_or_else(std::sync::PoisonError::into_inner)
630        .insert(format!("{topic}\0{}\0{detail}", cwd.display()))
631}
632
633/// Name a repository inside a state for a report. The root carries an empty
634/// prefix, and calling that "" helps nobody.
635fn describe_repository(prefix: &Path) -> String {
636    if prefix.as_os_str().is_empty() {
637        "the worktree".to_string()
638    } else {
639        format!("the submodule at {:?}", prefix.as_os_str())
640    }
641}
642
643/// The keys whose values differ between two maps, present on either side.
644fn differing_keys<'a, V: PartialEq>(
645    before: &'a BTreeMap<PathBuf, V>,
646    after: &'a BTreeMap<PathBuf, V>,
647) -> impl Iterator<Item = &'a PathBuf> {
648    let mut keys: BTreeSet<&'a PathBuf> = before.keys().collect();
649    keys.extend(after.keys());
650    keys.into_iter()
651        .filter(move |key| before.get(*key) != after.get(*key))
652}
653
654/// What a read-only call left in the untracked and ignored tree.
655///
656/// Split rather than reduced to a yes or no, because the two halves are
657/// answered differently: one ends the call, the other is said out loud and the
658/// answer is kept.
659#[derive(Default)]
660pub(crate) struct ReadOnlyLeavings {
661    /// Disposable on both sides, so out of scope for a call that only read.
662    /// Recognized build output is left out: rebuilding is what a reviewer was
663    /// asked to do, and nothing reports it today.
664    pub(crate) tolerated: Vec<PathBuf>,
665    /// Everything else. The answer does not survive these.
666    pub(crate) condemning: Vec<PathBuf>,
667}
668
669/// Put two accounts of what moved into one sentence, keeping whichever of them
670/// there is. Both guards that report a changed worktree ask the tracked tree
671/// and the untracked tree separately, and either can be the only answer.
672pub(crate) fn join_reasons(first: Option<String>, second: Option<String>) -> Option<String> {
673    match (first, second) {
674        (None, None) => None,
675        (Some(one), None) | (None, Some(one)) => Some(one),
676        (Some(first), Some(second)) => Some(format!("{first}; {second}")),
677    }
678}
679
680/// Name some paths for a person, without printing a build directory at them.
681///
682/// Every report of a path that stopped or was let through a call goes through
683/// this, so an error and the warning beside it read alike.
684pub(crate) fn list_paths(paths: &[PathBuf]) -> String {
685    const SHOWN: usize = 5;
686    let mut listed = paths
687        .iter()
688        .take(SHOWN)
689        .map(|path| format!("{:?}", path.as_os_str()))
690        .collect::<Vec<_>>()
691        .join(", ");
692    if paths.len() > SHOWN {
693        listed.push_str(&format!(", and {} more", paths.len() - SHOWN));
694    }
695    listed
696}
697
698/// Ignored files one commit attempt let through, gathered for a single report.
699///
700/// The checks that allow it run more than once per attempt, before staging,
701/// after staging, and again after the commit, because the tree could have moved
702/// under any of them. Reporting from inside each check said the same thing
703/// about the same files two and three times over.
704#[derive(Default)]
705struct IgnoredArtifacts {
706    new_paths: BTreeSet<PathBuf>,
707    changed_paths: BTreeSet<PathBuf>,
708    removed_paths: BTreeSet<PathBuf>,
709}
710
711impl IgnoredArtifacts {
712    fn left(&mut self, allowed: AllowedArtifacts) {
713        self.new_paths.extend(allowed.present);
714        self.removed_paths.extend(allowed.removed);
715    }
716
717    fn changed(&mut self, allowed: AllowedArtifacts) {
718        self.changed_paths.extend(allowed.present);
719        self.removed_paths.extend(allowed.removed);
720    }
721
722    /// Report paths without claiming ignored files are safe to delete.
723    fn report(&self, cwd: &Path) {
724        let paths: BTreeSet<_> = self
725            .new_paths
726            .iter()
727            .chain(&self.changed_paths)
728            .chain(&self.removed_paths)
729            .cloned()
730            .collect();
731        if !paths.is_empty() {
732            logdim!(
733                "the editing call changed {} ignored file(s) in {}: {}. They are outside \
734                 the commit; cleanup checks them separately.",
735                paths.len(),
736                cwd.display(),
737                list_paths(&paths.into_iter().collect::<Vec<_>>())
738            );
739        }
740    }
741}
742
743/// Ignored files a check let through, split by whether each one
744/// is still on disk, so the report can say what happened rather than call a
745/// deleted incremental session a file that was left behind.
746#[derive(Default)]
747struct AllowedArtifacts {
748    present: Vec<PathBuf>,
749    removed: Vec<PathBuf>,
750}
751
752impl AllowedArtifacts {
753    fn split(paths: Vec<PathBuf>, after: &IgnoredState) -> Self {
754        let (present, removed) = paths
755            .into_iter()
756            .partition(|path| after.files.contains_key(path));
757        Self { present, removed }
758    }
759}
760
761/// Generated directories that test and build commands routinely create.
762///
763/// Files under these directories are never committed, and they do not keep a
764/// worktree that is otherwise finished, so running the requested tests neither
765/// stops the tracked change reaching review nor leaves a checkout behind.
766fn is_generated_artifact(path: &Path) -> bool {
767    const DIRECTORIES: &[&str] = &[
768        "target",
769        "dist",
770        "node_modules",
771        "__pycache__",
772        ".pytest_cache",
773        ".mypy_cache",
774        ".ruff_cache",
775        ".tox",
776        ".nox",
777        ".venv",
778        "venv",
779        ".gradle",
780        ".build",
781        "DerivedData",
782        ".next",
783        ".nuxt",
784        ".svelte-kit",
785        ".turbo",
786        "coverage",
787    ];
788    path.components().any(|component| {
789        let std::path::Component::Normal(name) = component else {
790            return false;
791        };
792        DIRECTORIES
793            .iter()
794            .any(|directory| name == OsStr::new(directory))
795    })
796}
797
798fn merge_pr_args<'a>(
799    number: &'a str,
800    expected_head: Option<&'a str>,
801    delete_branch: bool,
802) -> Vec<&'a str> {
803    let mut args = vec!["pr", "merge", number, "--squash"];
804    if delete_branch {
805        args.push("--delete-branch");
806    }
807    if let Some(expected_head) = expected_head {
808        args.extend(["--match-head-commit", expected_head]);
809    }
810    args
811}
812
813fn reconcile_pr_creation(
814    branch: &str,
815    created: Result<String>,
816    found: Result<Option<PrRef>>,
817) -> Result<PrRef> {
818    match (created, found) {
819        (_, Ok(Some(pr))) => Ok(pr),
820        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
821            "PR creation reported success but none was found for {branch}"
822        ))),
823        (Err(create), Ok(None)) => Err(spar_err!(
824            "could not open a PR for {branch}. {}",
825            create.last_line()
826        )),
827        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
828            "PR creation reported success for {branch}, but it could not be verified. {}",
829            check.last_line()
830        ))),
831        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
832            "could not open a PR for {branch}. {} The result could not be verified: {}",
833            create.last_line(),
834            check.last_line()
835        ))),
836    }
837}
838
839fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
840    #[derive(Deserialize)]
841    #[serde(rename_all = "camelCase")]
842    struct Row {
843        number: i64,
844        #[serde(default)]
845        url: String,
846        #[serde(default)]
847        title: String,
848        base_ref_name: String,
849    }
850
851    let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
852        spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
853    })?;
854    Ok(rows
855        .into_iter()
856        .find(|row| row.base_ref_name == base)
857        .map(|row| PrRef {
858            number: row.number,
859            url: row.url,
860            title: row.title,
861        }))
862}
863
864/// Sign a comment spar composed, so it can be told from one the same account
865/// wrote by hand. Idempotent: a body already signed is returned as it is.
866fn signed(body: &str) -> String {
867    if body.contains(COMMENT_MARKER) {
868        return body.to_string();
869    }
870    format!("{body}\n\n{COMMENT_MARKER}")
871}
872
873/// Whether a comment body is one spar composed rather than one a person wrote.
874pub fn is_spar_comment(body: &str) -> bool {
875    body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
876}
877
878fn has_exact_comment(comments: &[Value], body: &str) -> bool {
879    comments.iter().any(|comment| {
880        comment
881            .get("body")
882            .and_then(Value::as_str)
883            .is_some_and(|seen| seen == body)
884    })
885}
886
887fn reconcile_comment_post(
888    number: i64,
889    body: &str,
890    post_error: crate::error::SparError,
891    comments: Result<Vec<Value>>,
892) -> Result<()> {
893    match comments {
894        Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
895        Ok(_) => Err(post_error),
896        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
897            "could not comment on #{number}. {} The result could not be verified: {}",
898            post_error.last_line(),
899            read_error.last_line()
900        ))),
901    }
902}
903
904fn reconcile_issue_edit(
905    number: i64,
906    wanted: &str,
907    edit_error: crate::error::SparError,
908    observed: Result<String>,
909) -> Result<()> {
910    match observed {
911        Ok(body) if body == wanted => Ok(()),
912        Ok(_) => Err(spar_err!(
913            "could not rewrite the body of #{number}. {}",
914            edit_error.last_line()
915        )),
916        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
917            "could not rewrite the body of #{number}. {} The result could not be verified: {}",
918            edit_error.last_line(),
919            read_error.last_line()
920        ))),
921    }
922}
923
924fn issue_url_has_number(url: &str) -> bool {
925    url.trim()
926        .rsplit('/')
927        .next()
928        .and_then(|tail| tail.parse::<i64>().ok())
929        .is_some_and(|number| number > 0)
930}
931
932fn reconcile_issue_creation(
933    title: &str,
934    created: Result<String>,
935    found: Result<Option<ExistingIssue>>,
936) -> Result<String> {
937    match (created, found) {
938        (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
939        (_, Ok(Some(issue))) => Ok(issue.url),
940        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
941            "issue creation reported success but no matching issue was found for {title:?}"
942        ))),
943        (Err(create), Ok(None)) => Err(spar_err!(
944            "could not file issue {title:?}. {}",
945            create.last_line()
946        )),
947        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
948            "issue creation reported success for {title:?}, but it could not be verified. {}",
949            check.last_line()
950        ))),
951        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
952            "could not file issue {title:?}. {} The result could not be verified: {}",
953            create.last_line(),
954            check.last_line()
955        ))),
956    }
957}
958
959fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
960    if output.trim().is_empty() {
961        return Ok(None);
962    }
963    for line in output.lines() {
964        let mut fields = line.split_whitespace();
965        let oid = fields.next().unwrap_or_default();
966        let name = fields.next().unwrap_or_default();
967        if name == remote_ref && !oid.is_empty() {
968            return Ok(Some(oid.to_string()));
969        }
970    }
971    Err(spar_err!(
972        "origin returned an unexpected ref listing for {remote_ref}"
973    ))
974}
975
976fn reconcile_failed_split_push(
977    branch: &str,
978    push_error: crate::error::SparError,
979    local: Result<String>,
980    remote: Result<String>,
981) -> std::result::Result<(), SplitPushError> {
982    let remote_ref = format!("refs/heads/{branch}");
983    match (local, remote) {
984        (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
985            Ok(Some(oid)) if oid == local.trim() => Ok(()),
986            Ok(_) => Err(SplitPushError::new(
987                format!(
988                    "could not create origin/{branch}. {} The remote branch is absent or points \
989                     somewhere else. Nothing was overwritten.",
990                    push_error.last_line()
991                ),
992                false,
993            )),
994            Err(check) => Err(SplitPushError::new(
995                format!(
996                    "could not confirm whether origin/{branch} was created. {} The remote result \
997                     could not be verified: {}",
998                    push_error.last_line(),
999                    check.last_line()
1000                ),
1001                true,
1002            )),
1003        },
1004        (local, remote) => {
1005            let check = match (local, remote) {
1006                (Err(local), Err(remote)) => format!(
1007                    "the local commit could not be read: {}; origin could not be read: {}",
1008                    local.last_line(),
1009                    remote.last_line()
1010                ),
1011                (Err(local), _) => {
1012                    format!("the local commit could not be read: {}", local.last_line())
1013                }
1014                (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1015                _ => unreachable!(),
1016            };
1017            Err(SplitPushError::new(
1018                format!(
1019                    "could not confirm whether origin/{branch} was created. {} The result could \
1020                     not be verified because {check}",
1021                    push_error.last_line()
1022                ),
1023                true,
1024            ))
1025        }
1026    }
1027}
1028
1029impl Repo {
1030    pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1031        let root =
1032            std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1033        // A linked worktree has a `.git` file rather than a directory, and a
1034        // bare-ish layout can have neither, so ask git instead of guessing.
1035        let inside = proc::run_str(
1036            &["git", "rev-parse", "--is-inside-work-tree"],
1037            &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1038        )
1039        .unwrap_or_default();
1040        if inside.trim() != "true" {
1041            bail!("not a git repository: {}", root.display());
1042        }
1043        let repo = Self {
1044            root,
1045            style: cfg.style.clone(),
1046            branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1047            state_store: cfg.loop_cfg.state_store,
1048            followups: cfg.loop_cfg.followups,
1049            drafts: cfg.loop_cfg.drafts,
1050            viewer: OnceLock::new(),
1051            checkpoints: Mutex::new(BTreeMap::new()),
1052            writes: WriteStats::default(),
1053        };
1054        repo.self_exclude();
1055        Ok(repo)
1056    }
1057
1058    /// Keep spar's own scratch directories out of the target repo's
1059    /// `git status`.
1060    ///
1061    /// Written to `.git/info/exclude`, never to a tracked `.gitignore`: this is
1062    /// somebody else's repository and spar has no business committing to it.
1063    /// Best effort and silent on failure, because a read-only git directory is
1064    /// not a reason to abandon a run.
1065    fn self_exclude(&self) {
1066        let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1067        let git_dir = git_dir.trim();
1068        if git_dir.is_empty() {
1069            return;
1070        }
1071        let path = Path::new(git_dir).join("info").join("exclude");
1072        let existing = std::fs::read_to_string(&path).unwrap_or_default();
1073
1074        // The marker too. It is written into whichever directory the call ran
1075        // in, which for a shared checkout is the repository root, and an
1076        // untracked file there is one `git add -A` away from being committed.
1077        // The error names its path, which is how a person is meant to find it.
1078        let wanted = [
1079            format!("/{WORKTREE_DIR}/"),
1080            format!("/{STATE_DIR}/"),
1081            format!("{RECOVERY_MARKER_PREFIX}*"),
1082        ];
1083        let missing: Vec<&String> = wanted
1084            .iter()
1085            .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1086            .collect();
1087        if missing.is_empty() {
1088            return;
1089        }
1090
1091        use std::io::Write;
1092        if let Some(parent) = path.parent() {
1093            let _ = std::fs::create_dir_all(parent);
1094        }
1095        let mut block = String::new();
1096        if !existing.is_empty() && !existing.ends_with('\n') {
1097            block.push('\n');
1098        }
1099        block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1100        for line in missing {
1101            block.push_str(line);
1102            block.push('\n');
1103        }
1104        if let Ok(mut file) = std::fs::OpenOptions::new()
1105            .create(true)
1106            .append(true)
1107            .open(&path)
1108        {
1109            let _ = file.write_all(block.as_bytes());
1110        }
1111    }
1112
1113    pub fn root(&self) -> &Path {
1114        &self.root
1115    }
1116
1117    pub(crate) fn write_summary(&self) -> WriteSummary {
1118        WriteSummary {
1119            attempted: self.writes.attempted.load(Ordering::Relaxed),
1120            failed: self.writes.failed.load(Ordering::Relaxed),
1121        }
1122    }
1123
1124    pub(crate) fn record_write<T, E>(
1125        &self,
1126        result: std::result::Result<T, E>,
1127    ) -> std::result::Result<T, E> {
1128        self.record_write_outcome(result.is_err());
1129        result
1130    }
1131
1132    pub(crate) fn record_failed_write<T, E>(
1133        &self,
1134        result: std::result::Result<T, E>,
1135    ) -> std::result::Result<T, E> {
1136        if result.is_err() {
1137            self.record_write_outcome(true);
1138        }
1139        result
1140    }
1141
1142    fn record_write_outcome(&self, failed: bool) {
1143        self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1144        if failed {
1145            self.writes.failed.fetch_add(1, Ordering::Relaxed);
1146        }
1147    }
1148
1149    // -- gates ------------------------------------------------------------
1150
1151    /// Scrub, then verify. A leak here reaches GitHub, so it is a hard error
1152    /// rather than a warning: silent partial compliance is how a style rule
1153    /// erodes over a long run.
1154    pub fn clean(&self, text: &str) -> Result<String> {
1155        let out = style::scrub(text, &self.style);
1156        let bad = style::violations(&out, &self.style);
1157        if !bad.is_empty() {
1158            bail!(
1159                "style gate could not clean text ({}): {}",
1160                bad.join(", "),
1161                style::clip(&out, 300)
1162            );
1163        }
1164        Ok(out)
1165    }
1166
1167    /// Clean, and hold to a length budget. For anything a model wrote.
1168    pub fn clean_body(&self, text: &str) -> Result<String> {
1169        self.clean(&style::body(text, &self.style))
1170    }
1171
1172    /// The same, with an issue's far larger budget and its exemption for code.
1173    pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1174        self.clean(&style::issue_body(text, &self.style))
1175    }
1176
1177    /// The single transform every outbound title goes through.
1178    ///
1179    /// Scrub first, clip second, and never the other way round. Clipping first
1180    /// lets the scrub lengthen the result past the budget (an em dash becomes
1181    /// two characters), so a second pass would clip again and produce a
1182    /// different string. That broke follow-up deduplication silently: the
1183    /// lookup searched for one title while GitHub had stored another, no match
1184    /// was ever found, and a fresh duplicate issue was filed every review
1185    /// round. Doing it in this order makes the transform idempotent, which the
1186    /// tests assert.
1187    pub fn clean_title(&self, text: &str) -> Result<String> {
1188        Ok(style::title(&self.clean(text)?, &self.style))
1189    }
1190
1191    pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1192        let title = self.record_failed_write(self.clean_title(text))?;
1193        if title.trim().is_empty() {
1194            return self.record_failed_write(Err(spar_err!(
1195                "nothing left of the title after cleaning it"
1196            )));
1197        }
1198        Ok(title)
1199    }
1200
1201    pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1202        if self.followups == Followups::Issues {
1203            self.clean_nonempty_title_for_write(text)
1204        } else {
1205            self.clean_title(text)
1206        }
1207    }
1208
1209    // -- git --------------------------------------------------------------
1210
1211    fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1212        ExecOpts::new()
1213            .cwd(cwd.unwrap_or(&self.root))
1214            .check(check)
1215            .timeout_secs(600)
1216    }
1217
1218    pub fn git(&self, args: &[&str]) -> Result<String> {
1219        self.git_at(None, args)
1220    }
1221
1222    pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1223        let argv = git_without_maintenance_argv(args);
1224        proc::run(&argv, &self.git_opts(cwd, true))
1225    }
1226
1227    /// Run a parent-side Git operation without inherited background helpers.
1228    ///
1229    /// Editing calls are untrusted. Once one returns, status, staging, and
1230    /// committing happen in this process, so an inherited fsmonitor or automatic
1231    /// maintenance command must not become a way to execute outside its sandbox.
1232    fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1233        let argv = git_without_automation_argv(args);
1234        proc::run(
1235            &argv,
1236            &self.git_opts(Some(cwd), true).stop_descendants(true),
1237        )
1238    }
1239
1240    fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1241        let argv = git_without_automation_argv(args);
1242        proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1243            .map(|output| output.ok())
1244    }
1245
1246    /// Run git, tolerating failure. Returns whatever landed on stdout.
1247    pub fn git_try(&self, args: &[&str]) -> String {
1248        self.git_try_at(None, args)
1249    }
1250
1251    pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1252        let argv = git_without_maintenance_argv(args);
1253        proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1254    }
1255
1256    /// The base branch the remote actually points at, rather than assuming
1257    /// `main`. Falls back to the configured value when there is no origin.
1258    pub fn default_branch(&self, configured: &str) -> String {
1259        let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1260        match refname.trim().rsplit('/').next() {
1261            Some(name) if !name.is_empty() => name.to_string(),
1262            _ => configured.to_string(),
1263        }
1264    }
1265
1266    // -- branch naming and ownership --------------------------------------
1267    //
1268    // Branch names default to `issue-N`, which is exactly what a person would
1269    // name a branch by hand. Ownership therefore cannot be inferred from the
1270    // name, so every branch spar creates is recorded and cleanup only ever
1271    // touches what is in that record.
1272
1273    pub fn branch_for_issue(&self, issue: i64) -> String {
1274        format!("{}issue-{issue}", self.branch_prefix)
1275    }
1276
1277    pub fn branch_for_pr(&self, number: i64) -> String {
1278        format!("{}pr-{number}", self.branch_prefix)
1279    }
1280
1281    /// One part of a split, numbered from 1 within its parent.
1282    ///
1283    /// Its own namespace rather than `issue-N`, because the parts of a split
1284    /// pull request have no issue of their own and would otherwise collide with
1285    /// the branch of the issue that happens to share the parent's number.
1286    ///
1287    /// The name a part is tried on first. `worktree_for_split` may end up on a
1288    /// suffixed one, because this name is not free forever.
1289    pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1290        format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1291    }
1292
1293    fn ledger_path(&self) -> PathBuf {
1294        self.root.join(STATE_DIR).join("branches.json")
1295    }
1296
1297    pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1298        std::fs::read_to_string(self.ledger_path())
1299            .ok()
1300            .and_then(|text| serde_json::from_str(&text).ok())
1301            .unwrap_or_default()
1302    }
1303
1304    pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1305        let mut data = self.known_branches();
1306        data.insert(
1307            branch.to_string(),
1308            BranchRecord {
1309                kind: kind.to_string(),
1310                number,
1311            },
1312        );
1313        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1314            logdim!("could not record branch {branch}: {e}");
1315        }
1316    }
1317
1318    pub fn forget_branch(&self, branch: &str) {
1319        let mut data = self.known_branches();
1320        if data.remove(branch).is_none() {
1321            return;
1322        }
1323        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1324            logdim!("could not update the branch record: {e}");
1325        }
1326    }
1327
1328    // -- worktrees --------------------------------------------------------
1329
1330    fn worktree_path(&self, name: &str) -> PathBuf {
1331        self.root.join(WORKTREE_DIR).join(name)
1332    }
1333
1334    /// Isolate an issue so a failed run cannot poison the next one's base.
1335    pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1336        let branch = self.branch_for_issue(issue);
1337        let path = self.worktree_path(&format!("issue-{issue}"));
1338
1339        self.refuse_issue_branch_rebuild(issue, base)?;
1340        self.refuse_dirty_worktree(
1341            &path,
1342            &format!("worktree for issue #{issue}"),
1343            Disposable::BuildOutput,
1344        )?;
1345
1346        if !self.branch_deletion_is_safe(&branch)? {
1347            bail!(
1348                "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1349                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1350                 before retrying."
1351            );
1352        }
1353
1354        if !self.remove_worktree_at(&path)? {
1355            bail!(
1356                "the existing worktree for issue #{issue} could not be removed safely. Its \
1357                 branch was kept."
1358            );
1359        }
1360        if !self.delete_branch_if_safe(&branch)? {
1361            bail!(
1362                "the existing branch {branch} changed or remained checked out while its \
1363                 worktree was being rebuilt. It was kept."
1364            );
1365        }
1366
1367        if let Some(parent) = path.parent() {
1368            std::fs::create_dir_all(parent)
1369                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1370        }
1371
1372        let path_str = path.display().to_string();
1373        let remote_start = format!("origin/{base}");
1374        let created = self
1375            .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1376            .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1377
1378        // Recorded on both paths: an unrecorded branch is one cleanup will
1379        // never remove, and the fallback creates a branch just the same.
1380        created.map_err(|e| {
1381            spar_err!(
1382                "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1383                 and does `origin` exist?",
1384                e.last_line()
1385            )
1386        })?;
1387        self.record_branch(&branch, "issue", issue);
1388        Ok((path, branch))
1389    }
1390
1391    /// Refuse to reset the local or remote branch assigned to an issue when it
1392    /// carries work that no pull request preserves.
1393    ///
1394    /// Both linked-worktree mode and shared-checkout mode rebuild the same
1395    /// branch name. Keeping the guard here prevents either path from silently
1396    /// replacing recovery commits left by an earlier run.
1397    pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1398        let branch = self.branch_for_issue(issue);
1399        let base_remote_ref = format!("refs/heads/{base}");
1400        let base_tracking_ref = format!("refs/remotes/origin/{base}");
1401        let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1402        self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1403            .map_err(|e| {
1404                spar_err!(
1405                    "could not refresh origin/{base} before checking issue #{issue}: {}",
1406                    e.last_line()
1407                )
1408            })?;
1409
1410        if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
1411            let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
1412            if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
1413                bail!(
1414                    "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
1415                     pull request accounts for them. Rebuilding it would force push over that \
1416                     work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
1417                     it, or delete it with `git push origin --delete {branch}` if the remote \
1418                     branch is no longer needed."
1419                );
1420            }
1421        }
1422
1423        let local_ref = format!("refs/heads/{branch}");
1424        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1425            let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
1426            let recorded_pr = self
1427                .known_branches()
1428                .get(&branch)
1429                .is_some_and(|record| record.kind == "pr");
1430            let preserved = ahead == 0
1431                || if recorded_pr {
1432                    self.local_branch_is_preserved(&branch)?
1433                } else {
1434                    self.pull_request_holds(&branch, &local_ref, base)
1435                };
1436            if !preserved {
1437                let listed = self
1438                    .commit_lines(&self.root, &local_ref, base)
1439                    .iter()
1440                    .map(|line| format!("  {line}"))
1441                    .collect::<Vec<_>>()
1442                    .join("\n");
1443                bail!(
1444                    "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
1445                     no pull request preserves them. Rebuilding it would delete the only copy.\n\
1446                     {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
1447                     it, or delete it with `git branch -D {branch}` if it is stale."
1448                );
1449            }
1450        }
1451        Ok(())
1452    }
1453
1454    fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
1455        let live_ref = format!("refs/heads/{branch}");
1456        let tracking_ref = format!("refs/remotes/origin/{branch}");
1457        let listed = self
1458            .git(&["ls-remote", "--heads", "origin", &live_ref])
1459            .map_err(|e| {
1460                spar_err!(
1461                    "could not verify whether origin/{branch} still exists: {}",
1462                    e.last_line()
1463                )
1464            })?;
1465
1466        if remote_head_oid(&listed, &live_ref)?.is_some() {
1467            let refspec = format!("+{live_ref}:{tracking_ref}");
1468            self.git(&["fetch", "--no-tags", "origin", &refspec])
1469                .map_err(|e| {
1470                    spar_err!(
1471                        "origin/{branch} exists but its tracking ref could not be refreshed: {}",
1472                        e.last_line()
1473                    )
1474                })?;
1475            if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1476                bail!("origin/{branch} was fetched but its tracking ref is missing");
1477            }
1478            return Ok(Some(tracking_ref));
1479        }
1480
1481        if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1482            return Ok(None);
1483        }
1484        let expected = self
1485            .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
1486            .trim()
1487            .to_string();
1488        self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
1489            .map_err(|e| {
1490                spar_err!(
1491                    "could not discard stale origin/{branch} tracking ref safely: {}",
1492                    e.last_line()
1493                )
1494            })?;
1495        if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1496            bail!(
1497                "origin/{branch} changed while its stale tracking ref was being removed. It was \
1498                 kept."
1499            );
1500        }
1501        Ok(None)
1502    }
1503
1504    /// Whether a pull request from `branch` already holds every commit `refname`
1505    /// has beyond `base`.
1506    ///
1507    /// GitHub serves `refs/pull/N/head` for as long as the repository lives, so
1508    /// commits that reached a pull request outlive the branch they were pushed
1509    /// from. A matching branch name does not establish that on its own: an
1510    /// issue worked twice reuses the name, and the merged pull request from the
1511    /// first round says nothing about where the second round's commits are.
1512    fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
1513        self.prs_for_branch(branch)
1514            .iter()
1515            .any(|pr| self.pr_head_holds(pr.number, refname, base))
1516    }
1517
1518    fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
1519        let head = format!("refs/spar/pr-head/{number}");
1520        let refspec = format!("+refs/pull/{number}/head:{head}");
1521        if self.git(&["fetch", "origin", &refspec]).is_err() {
1522            return false;
1523        }
1524        let held = self.commits_held_by(refname, base, &head);
1525        self.git_try(&["update-ref", "-d", &head]);
1526        held
1527    }
1528
1529    pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
1530        let argv = vec![
1531            "git".to_string(),
1532            "merge-base".to_string(),
1533            "--is-ancestor".to_string(),
1534            older.to_string(),
1535            newer.to_string(),
1536        ];
1537        let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
1538        match out.code {
1539            0 => Ok(true),
1540            1 => Ok(false),
1541            _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
1542        }
1543    }
1544
1545    fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
1546        let head = format!("refs/spar/pr-head/{number}");
1547        let refspec = format!("+refs/pull/{number}/head:{head}");
1548        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1549            spar_err!(
1550                "could not verify the immutable head of PR #{number}: {}",
1551                e.last_line()
1552            )
1553        })?;
1554        let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
1555        self.git_try(&["update-ref", "-d", &head]);
1556        held
1557    }
1558
1559    fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
1560        let text = self.gh(&[
1561            "pr",
1562            "list",
1563            "--head",
1564            branch,
1565            "--state",
1566            "all",
1567            "--json",
1568            "number,url,title",
1569        ])?;
1570        serde_json::from_str(text.trim())
1571            .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
1572    }
1573
1574    fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
1575        let branch_ref = format!("refs/heads/{branch}");
1576        if record.kind == "pr" {
1577            return self.pr_head_contains_checked(record.number, &branch_ref);
1578        }
1579        let prs = self.branch_prs_checked(branch)?;
1580        if prs.is_empty() {
1581            return Ok(false);
1582        }
1583        for pr in prs {
1584            if self.pr_head_contains_checked(pr.number, &branch_ref)? {
1585                return Ok(true);
1586            }
1587        }
1588        Ok(false)
1589    }
1590
1591    fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
1592        let local_ref = format!("refs/heads/{branch}");
1593        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1594            return Ok(true);
1595        }
1596        let oid = self
1597            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1598            .trim()
1599            .to_string();
1600        let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
1601        if !durable_tip {
1602            let remote_ref = format!("refs/heads/{branch}");
1603            let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
1604            durable_tip = remote.lines().any(|line| {
1605                line.split_whitespace()
1606                    .next()
1607                    .is_some_and(|remote_oid| remote_oid == oid)
1608            });
1609        }
1610        if !durable_tip {
1611            if let Some(record) = self.known_branches().get(branch) {
1612                durable_tip = self.branch_is_preserved_checked(branch, record)?;
1613            }
1614        }
1615        if !durable_tip {
1616            return Ok(false);
1617        }
1618        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1619    }
1620
1621    /// Delete a branch only if its exact current tip and reflog are still safe.
1622    /// The expected old value makes a concurrent ref update fail instead of
1623    /// deleting work that appeared after the preservation check.
1624    fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
1625        let local_ref = format!("refs/heads/{branch}");
1626        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1627            return Ok(true);
1628        }
1629        let expected = self
1630            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1631            .trim()
1632            .to_string();
1633        if !self.branch_deletion_is_safe(branch)? {
1634            return Ok(false);
1635        }
1636        let checked_out = self
1637            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
1638            .lines()
1639            .any(|line| line == format!("branch {local_ref}"));
1640        if checked_out {
1641            return Ok(false);
1642        }
1643        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1644        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1645    }
1646
1647    fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
1648        let local_ref = review_ref(number);
1649        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1650            return Ok(true);
1651        }
1652        let oid = self
1653            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1654            .trim()
1655            .to_string();
1656        if !self.pr_head_contains_checked(number, &local_ref)? {
1657            return Ok(false);
1658        }
1659        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1660    }
1661
1662    fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
1663        let local_ref = review_ref(number);
1664        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1665            return Ok(true);
1666        }
1667        let expected = self
1668            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1669            .trim()
1670            .to_string();
1671        if !self.review_ref_deletion_is_safe(number)? {
1672            return Ok(false);
1673        }
1674        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1675        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1676    }
1677
1678    /// Whether `other` already contains every commit `branch` has beyond
1679    /// `base`. False when either ref fails to resolve, so a ref that is not
1680    /// there cannot vouch for anything.
1681    pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
1682        let range = format!("{}..{branch}", self.base_ref(&self.root, base));
1683        self.git_try(&["rev-list", "--count", &range, "--not", other])
1684            .trim()
1685            == "0"
1686    }
1687
1688    pub fn worktree_remove(&self, issue: i64) -> bool {
1689        let path = self.worktree_path(&format!("issue-{issue}"));
1690        match self.remove_worktree_at(&path) {
1691            Ok(removed) => removed,
1692            Err(error) => {
1693                logdim!(
1694                    "kept {} because removal did not reach a confirmed quiet point: {}",
1695                    path.display(),
1696                    error.last_line()
1697                );
1698                false
1699            }
1700        }
1701    }
1702
1703    /// Verify both the common Git directory and the worktree top level.
1704    ///
1705    /// A stale worktree entry is not ownership proof. An unrelated repository
1706    /// can later occupy the same path and must survive cleanup.
1707    fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
1708        let wanted = std::fs::canonicalize(path)
1709            .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
1710        // Every SPAR worktree path is built from the canonical repository root.
1711        // A different canonical path therefore means the final component or
1712        // one of its parents is a symlink. Passing that alias to `git worktree
1713        // remove` can delete the worktree at its real target.
1714        if wanted != path {
1715            return Ok(false);
1716        }
1717        let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
1718            let raw = PathBuf::from(value.trim());
1719            let joined = if raw.is_absolute() {
1720                raw
1721            } else {
1722                cwd.join(raw)
1723            };
1724            std::fs::canonicalize(&joined)
1725                .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
1726        };
1727        let expected =
1728            self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
1729        let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
1730        let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
1731        let expected = resolve(&self.root, &expected)?;
1732        let actual = resolve(path, &actual)?;
1733        let top = resolve(path, &top)?;
1734        Ok(expected == actual && top == wanted)
1735    }
1736
1737    /// Remove only a worktree that belongs to this repository.
1738    ///
1739    /// The path sits under a predictable directory, but that does not establish
1740    /// ownership. A clean independent repository at the same path must survive
1741    /// even when `git worktree remove` rejects it.
1742    fn remove_worktree_at_with_force(
1743        &self,
1744        path: &Path,
1745        force: bool,
1746        disposable: Disposable,
1747    ) -> Result<bool> {
1748        let existed = path.exists();
1749        if path.exists() {
1750            match self.worktree_belongs_to_repo(path) {
1751                Ok(true) => {}
1752                Ok(false) => {
1753                    logdim!(
1754                        "kept {} because it is not a worktree owned by this repository",
1755                        path.display()
1756                    );
1757                    return Ok(false);
1758                }
1759                Err(e) => {
1760                    logdim!(
1761                        "kept {} because its worktree ownership could not be verified: {}",
1762                        path.display(),
1763                        e.last_line()
1764                    );
1765                    return Ok(false);
1766                }
1767            }
1768            if !force {
1769                match self.has_recoverable_work(path, disposable) {
1770                    Ok(true) => {
1771                        logdim!(
1772                            "kept {} because it contains recoverable files or repository state",
1773                            path.display()
1774                        );
1775                        return Ok(false);
1776                    }
1777                    Err(e) => {
1778                        logdim!(
1779                            "kept {} because its recoverable state could not be checked: {}",
1780                            path.display(),
1781                            e.last_line()
1782                        );
1783                        return Ok(false);
1784                    }
1785                    Ok(false) => {}
1786                }
1787            }
1788        }
1789        // Git refuses to remove a worktree holding any untracked file, which is
1790        // the same question SPAR has just answered in more detail: under
1791        // `ReviewOutput` the checks above have already established that nothing
1792        // there is worth keeping. `--force` here is that answer being carried
1793        // out, not the check being skipped, which is what `force` means.
1794        let path_str = path.display().to_string();
1795        let command_ok = if force || disposable == Disposable::ReviewOutput {
1796            self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
1797        } else {
1798            self.git_try_without_automation(&["worktree", "remove", &path_str])?
1799        };
1800        Ok((command_ok || !existed) && !path.exists())
1801    }
1802
1803    fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
1804        self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
1805    }
1806
1807    /// Remove a review checkout that is about to be rebuilt from origin.
1808    fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
1809        self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
1810    }
1811
1812    /// Force removal is reserved for the explicit `clean --all` path.
1813    fn remove_worktree_at_force(&self, path: &Path) -> bool {
1814        match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
1815            Ok(removed) => removed,
1816            Err(error) => {
1817                logdim!(
1818                    "kept {} because removal did not reach a confirmed quiet point: {}",
1819                    path.display(),
1820                    error.last_line()
1821                );
1822                false
1823            }
1824        }
1825    }
1826
1827    /// Remove a worktree only after a caller has verified it is unchanged.
1828    ///
1829    /// There is deliberately no force fallback, so Git can still refuse a
1830    /// removal if tracked or non-ignored work appears after the final check.
1831    fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
1832        if path.exists() && !self.worktree_belongs_to_repo(path)? {
1833            bail!(
1834                "{} is not a worktree owned by this repository, so it was kept",
1835                path.display()
1836            );
1837        }
1838        if path.exists() && self.has_recoverable_work(path, disposable)? {
1839            bail!(
1840                "the verified worktree at {} contains recoverable files or repository state. It \
1841                 was kept.",
1842                path.display()
1843            );
1844        }
1845        let path_str = path.display().to_string();
1846        self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
1847            .map_err(|e| {
1848                e.with_message(format!(
1849                    "could not remove the verified worktree at {}: {}. It was kept.",
1850                    path.display(),
1851                    e.last_line()
1852                ))
1853            })?;
1854        Ok(!path.exists())
1855    }
1856
1857    fn refuse_dirty_worktree(
1858        &self,
1859        path: &Path,
1860        label: &str,
1861        disposable: Disposable,
1862    ) -> Result<()> {
1863        if !path.is_dir() {
1864            return Ok(());
1865        }
1866        let has_files = std::fs::read_dir(path)
1867            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
1868            .next()
1869            .is_some();
1870        let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
1871            spar_err!(
1872                "could not verify whether the existing {label} at {} belongs to this repository, \
1873                 so it was kept: {}",
1874                path.display(),
1875                e.last_line()
1876            )
1877        })?;
1878        if !owned {
1879            if has_files {
1880                bail!(
1881                    "the existing {label} at {} is not a worktree owned by \
1882                     this repository. Refusing to remove it.",
1883                    path.display()
1884                );
1885            }
1886            return Ok(());
1887        }
1888        if !path.join(".git").exists() {
1889            if has_files {
1890                bail!(
1891                    "the existing {label} at {} is not a readable Git worktree and is not empty. \
1892                     Refusing to remove it.",
1893                    path.display()
1894                );
1895            }
1896            return Ok(());
1897        }
1898        let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
1899            spar_err!(
1900                "could not verify whether the existing {label} at {} is clean, so it was kept: \
1901                 {}",
1902                path.display(),
1903                e.last_line()
1904            )
1905        })?;
1906        if dirty {
1907            bail!(
1908                "the existing {label} contains uncommitted changes or ignored files at {}. \
1909                 Rebuilding it would delete those files.\nCommit or recover them before running this \
1910                 command again, or use `spar clean --all` if they are not needed.",
1911                path.display()
1912            );
1913        }
1914        Ok(())
1915    }
1916
1917    /// Check an existing PR branch out into an isolated worktree.
1918    pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
1919        let head = pr.head_ref_name.clone();
1920        if head.trim().is_empty() {
1921            bail!("PR #{} has no head branch to check out", pr.number);
1922        }
1923        let path = self.worktree_path(&format!("pr-{}", pr.number));
1924        let local = self.branch_for_pr(pr.number);
1925
1926        self.git(&["fetch", "origin", &head]).map_err(|e| {
1927            spar_err!(
1928                "could not fetch the branch behind PR #{}: {}",
1929                pr.number,
1930                e.last_line()
1931            )
1932        })?;
1933        let start = format!("origin/{head}");
1934        let start_ref = format!("refs/remotes/origin/{head}");
1935        let local_ref = format!("refs/heads/{local}");
1936        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1937            let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
1938            if unpushed > 0 {
1939                bail!(
1940                    "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
1941                     on {start}. Rebuilding it would delete their branch.\nInspect the worktree at \
1942                     {} and push or recover those commits before running this command again.",
1943                    pr.number,
1944                    path.display()
1945                );
1946            }
1947        }
1948        self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
1949        self.refuse_dirty_worktree(
1950            &path,
1951            &format!("worktree for PR #{}", pr.number),
1952            Disposable::BuildOutput,
1953        )?;
1954        if !self.branch_deletion_is_safe(&local)? {
1955            bail!(
1956                "the existing branch {local} has a tip or reflog-only commit that no surviving \
1957                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1958                 before retrying."
1959            );
1960        }
1961        if !self.remove_worktree_at(&path)? {
1962            bail!(
1963                "the existing worktree for PR #{} could not be removed safely. Its branch was \
1964                 kept.",
1965                pr.number
1966            );
1967        }
1968        if !self.delete_branch_if_safe(&local)? {
1969            bail!(
1970                "the existing branch {local} changed or remained checked out while the PR \
1971                 worktree was being rebuilt. It was kept."
1972            );
1973        }
1974
1975        let path_str = path.display().to_string();
1976        self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
1977        self.record_branch(&local, "pr", pr.number);
1978        Ok((path, head))
1979    }
1980
1981    /// A retry needs a fresh checkout, not permission to delete ignored data.
1982    /// Keep that data and its branch when all commits are already published.
1983    fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
1984        if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
1985            return Ok(());
1986        }
1987        let current =
1988            self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
1989        if current.trim() != branch
1990            || self.has_uncommitted_changes(path)?
1991            || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
1992        {
1993            return Ok(());
1994        }
1995        static NEXT: AtomicUsize = AtomicUsize::new(0);
1996        let (name, retained) = loop {
1997            let serial = NEXT.fetch_add(1, Ordering::Relaxed);
1998            let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
1999            let retained = self.worktree_path(&name);
2000            if !retained
2001                .try_exists()
2002                .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2003                && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2004            {
2005                break (name, retained);
2006            }
2007        };
2008        // Neither command overwrites an existing destination. The branch rename
2009        // preserves its reflog, and the worktree move preserves every file.
2010        self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2011        if let Err(error) = self.git_at_without_automation(
2012            &self.root,
2013            &[
2014                "worktree",
2015                "move",
2016                &path.display().to_string(),
2017                &retained.display().to_string(),
2018            ],
2019        ) {
2020            // A locked worktree or one with submodules cannot always be moved.
2021            // Restore the branch name so a failed move does not strand a retry.
2022            let rollback =
2023                self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2024            let detail = rollback
2025                .err()
2026                .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2027                .unwrap_or_default();
2028            bail!(
2029                "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2030                retained.display(),
2031                error.last_line()
2032            );
2033        }
2034        logdim!(
2035            "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2036            retained.display()
2037        );
2038        Ok(())
2039    }
2040
2041    /// Check a pull request's head out read only, detached, with no branch.
2042    ///
2043    /// Fetches `refs/pull/N/head`, which GitHub serves for every pull request
2044    /// including one from a fork whose branch is not in this repository at all.
2045    /// That is what makes reviewing an outside contribution possible when
2046    /// pushing to it is not.
2047    ///
2048    /// Detached on purpose. Review only mode has nothing to push, and a branch
2049    /// would only invite something to try.
2050    pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2051        let path = self.worktree_path(&format!("review-{number}"));
2052        let local_ref = review_ref(number);
2053        let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2054
2055        self.refuse_review_worktree_changes(number)?;
2056
2057        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2058            spar_err!(
2059                "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2060                 every pull request, so this usually means the number is wrong or `origin` does \
2061                 not point at the repository the PR is on.",
2062                e.last_line()
2063            )
2064        })?;
2065
2066        if let Some(parent) = path.parent() {
2067            std::fs::create_dir_all(parent)
2068                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2069        }
2070        if !self.remove_review_worktree_at(&path)? {
2071            bail!(
2072                "the existing review worktree for PR #{number} could not be removed safely. Its \
2073                 reference was kept."
2074            );
2075        }
2076        let path_str = path.display().to_string();
2077        self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2078        Ok(path)
2079    }
2080
2081    fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2082        let path = self.worktree_path(&format!("review-{number}"));
2083        if !path.is_dir() {
2084            return Ok(());
2085        }
2086        let local_ref = review_ref(number);
2087        if !self.worktree_belongs_to_repo(&path)? {
2088            return Ok(());
2089        }
2090        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2091            bail!(
2092                "the existing review worktree for PR #{number} has no recorded head at \
2093                 {local_ref}. Refusing to rebuild {}.",
2094                path.display()
2095            );
2096        }
2097        let worktree_head = self.head_oid_checked(&path)?;
2098        let recorded_head = self
2099            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2100            .trim()
2101            .to_string();
2102        if worktree_head != recorded_head {
2103            bail!(
2104                "the existing review worktree for PR #{number} has a local commit that is not on \
2105                 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2106                 {} before retrying.",
2107                path.display()
2108            );
2109        }
2110        self.report_recovery_markers(&path);
2111        self.refuse_dirty_worktree(
2112            &path,
2113            &format!("review worktree for PR #{number}"),
2114            Disposable::ReviewOutput,
2115        )?;
2116        Ok(())
2117    }
2118
2119    /// Read out what an earlier run refused to explain away, before rebuilding
2120    /// over it.
2121    ///
2122    /// The marker is the only record of why a call's answer was thrown out, and
2123    /// a review checkout is rebuilt on the next run. Saying it here is what
2124    /// keeps the file from being the thing that quietly blocks the command
2125    /// instead.
2126    fn report_recovery_markers(&self, path: &Path) {
2127        let Ok(entries) = std::fs::read_dir(path) else {
2128            return;
2129        };
2130        for entry in entries.flatten() {
2131            let name = entry.file_name();
2132            let Some(name) = name.to_str() else { continue };
2133            if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2134                continue;
2135            }
2136            match std::fs::read_to_string(entry.path()) {
2137                Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2138                Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2139            }
2140        }
2141    }
2142
2143    /// A worktree for one part of a split, on a new branch off `start`.
2144    ///
2145    /// `start` is the base branch for independent parts and the previous part's
2146    /// branch for stacked ones, which is the only difference between the two
2147    /// shapes at this level.
2148    ///
2149    /// The branch is whatever name was free, which is why it is returned rather
2150    /// than derived by the caller. Splitting the same pull request a second
2151    /// time would otherwise target the branch behind the first run's pull
2152    /// request. Split pushes are create-only and would refuse that target, but
2153    /// a repeated split still needs distinct branches rather than a name that
2154    /// can never be created.
2155    pub fn worktree_for_split(
2156        &self,
2157        parent: i64,
2158        index: usize,
2159        start: &str,
2160    ) -> Result<(PathBuf, String)> {
2161        let slot = self.free_split_slot(parent, index)?;
2162        let branch = format!("{}{slot}", self.branch_prefix);
2163        let path = self.worktree_path(&slot);
2164
2165        if let Some(dir) = path.parent() {
2166            std::fs::create_dir_all(dir)
2167                .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2168        }
2169        self.refuse_dirty_worktree(
2170            &path,
2171            &format!("worktree for part {index} of PR #{parent}"),
2172            Disposable::BuildOutput,
2173        )?;
2174        // The name is free, so there is no branch to delete. A directory can
2175        // still be in the way, left by a worktree that was pruned from git's
2176        // records without being removed from disk.
2177        if !self.remove_worktree_at(&path)? {
2178            bail!(
2179                "the existing worktree for part {index} of PR #{parent} could not be removed \
2180                 safely. No branch was created."
2181            );
2182        }
2183
2184        let path_str = path.display().to_string();
2185        self.git(&["worktree", "add", "-b", &branch, &path_str, start])
2186            .map_err(|e| {
2187                spar_err!(
2188                    "could not create a worktree for part {index} of #{parent}. {}",
2189                    e.last_line()
2190                )
2191            })?;
2192        // Recorded before anything else can fail. An unrecorded branch is one
2193        // `prune_branches` will never remove.
2194        self.record_branch(&branch, "split", parent);
2195        Ok((path, branch))
2196    }
2197
2198    /// The first part branch nothing is already sitting on.
2199    ///
2200    /// Origin as well as local, because a part's branch outlives the local one:
2201    /// a second split of the same pull request finds its own earlier branches
2202    /// deleted here but alive on origin, where the pull requests that reviewed
2203    /// them still point at them.
2204    fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2205        for attempt in 1..=SPLIT_SLOTS {
2206            let slot = split_slot(parent, index, attempt);
2207            let branch = format!("{}{slot}", self.branch_prefix);
2208            self.git_try(&["fetch", "origin", &branch]);
2209            if !self.rev_exists(&self.root, &branch)
2210                && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2211            {
2212                return Ok(slot);
2213            }
2214        }
2215        bail!(
2216            "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2217             names are all taken. Inspect the existing branches and child pull requests. Finish \
2218             recording the earlier split, or remove every retained local worktree and branch, \
2219             child pull request, and remote split branch before starting over.",
2220            self.branch_for_split(parent, index)
2221        )
2222    }
2223
2224    /// Whether a previous attempt pushed any branch for this split.
2225    ///
2226    /// The parent comment is the normal retry marker. A branch is the fallback
2227    /// when that comment or the pull request creation failed after the push.
2228    /// Reading origin directly makes the guard survive a fresh clone.
2229    pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2230        let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2231        Ok(!self
2232            .git(&["ls-remote", "--heads", "origin", &pattern])?
2233            .trim()
2234            .is_empty())
2235    }
2236
2237    /// Throw one part away: its worktree, its branch, and its record.
2238    ///
2239    /// For a part that would not stand on its own. Nothing has been pushed at
2240    /// that point, so this leaves no trace anywhere but the log. Takes what
2241    /// `worktree_for_split` returned, since the name it settled on is not
2242    /// derivable from the parent and the index.
2243    pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2244        match self.branch_deletion_is_safe(branch) {
2245            Ok(true) => {}
2246            Ok(false) => {
2247                logdim!(
2248                    "kept {branch} and {} because no surviving ref preserves its tip",
2249                    dir.display()
2250                );
2251                return;
2252            }
2253            Err(error) => {
2254                logdim!(
2255                    "kept {branch} and {} because preservation could not be verified: {}",
2256                    dir.display(),
2257                    error.last_line()
2258                );
2259                return;
2260            }
2261        }
2262        match self.remove_worktree_at(dir) {
2263            Ok(true) => match self.delete_branch_if_safe(branch) {
2264                Ok(true) => self.forget_branch(branch),
2265                Ok(false) => {
2266                    logdim!("kept {branch} because its tip or reflog changed before deletion")
2267                }
2268                Err(error) => logdim!(
2269                    "kept {branch} because deletion safety could not be rechecked: {}",
2270                    error.last_line()
2271                ),
2272            },
2273            Ok(false) => {}
2274            Err(error) => logdim!(
2275                "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
2276                dir.display(),
2277                error.last_line()
2278            ),
2279        }
2280    }
2281
2282    /// Discard one exact mechanical slice that the split workflow just made.
2283    ///
2284    /// Unlike ordinary release, this intentionally removes an unpushed commit.
2285    /// The caller supplies the exact disposable tip, and every file, worktree,
2286    /// ownership, and ref check must still match before anything is removed.
2287    pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
2288        let record = self.known_branches().get(branch).cloned();
2289        if record.is_none_or(|record| record.kind != "split") {
2290            logdim!("kept {branch} because no split branch record proves ownership");
2291            return false;
2292        }
2293        let local_ref = format!("refs/heads/{branch}");
2294        let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
2295            Ok(value) => value.trim().to_string(),
2296            Err(error) => {
2297                logdim!(
2298                    "kept {branch} because its tip could not be checked: {}",
2299                    error.last_line()
2300                );
2301                return false;
2302            }
2303        };
2304        if expected != disposable_head {
2305            logdim!("kept {branch} because it moved beyond the disposable slice");
2306            return false;
2307        }
2308        match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
2309            Ok(true) => {}
2310            Ok(false) => {
2311                logdim!(
2312                    "kept {branch} because its reflog contains work outside the disposable slice"
2313                );
2314                return false;
2315            }
2316            Err(error) => {
2317                logdim!(
2318                    "kept {branch} because its reflog could not be checked: {}",
2319                    error.last_line()
2320                );
2321                return false;
2322            }
2323        }
2324        match self.head_oid_checked(dir) {
2325            Ok(head) if head == disposable_head => {}
2326            Ok(_) => {
2327                logdim!(
2328                    "kept {branch} and {} because the worktree moved beyond the disposable slice",
2329                    dir.display()
2330                );
2331                return false;
2332            }
2333            Err(error) => {
2334                logdim!(
2335                    "kept {branch} and {} because its head could not be checked: {}",
2336                    dir.display(),
2337                    error.last_line()
2338                );
2339                return false;
2340            }
2341        }
2342        match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
2343            Ok(true) => {}
2344            Ok(false) => return false,
2345            Err(error) => {
2346                logdim!(
2347                    "kept {branch} and {} because the disposable slice could not be verified: {}",
2348                    dir.display(),
2349                    error.last_line()
2350                );
2351                return false;
2352            }
2353        }
2354        if let Err(error) =
2355            self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
2356        {
2357            logdim!(
2358                "kept {branch} because its exact disposable tip could not be deleted: {}",
2359                error.last_line()
2360            );
2361            return false;
2362        }
2363        match self.exact_ref_exists_checked(&self.root, &local_ref) {
2364            Ok(false) => {
2365                self.forget_branch(branch);
2366                true
2367            }
2368            Ok(true) => {
2369                logdim!("kept {branch} because its ref still exists after deletion");
2370                false
2371            }
2372            Err(error) => {
2373                logdim!(
2374                    "kept the branch record for {branch} because deletion could not be verified: {}",
2375                    error.last_line()
2376                );
2377                false
2378            }
2379        }
2380    }
2381
2382    pub fn release_review_worktree(&self, number: i64) {
2383        let path = self.worktree_path(&format!("review-{number}"));
2384        match self.review_ref_deletion_is_safe(number) {
2385            Ok(true) => {}
2386            Ok(false) => {
2387                logdim!(
2388                    "kept {} because no surviving ref preserves its review history",
2389                    path.display()
2390                );
2391                return;
2392            }
2393            Err(error) => {
2394                logdim!(
2395                    "kept {} because review history could not be verified: {}",
2396                    path.display(),
2397                    error.last_line()
2398                );
2399                return;
2400            }
2401        }
2402        match self.remove_worktree_at(&path) {
2403            Ok(true) => match self.delete_review_ref_if_safe(number) {
2404                Ok(true) => {}
2405                Ok(false) => logdim!(
2406                    "kept {} because its review history changed before deletion",
2407                    review_ref(number)
2408                ),
2409                Err(error) => logdim!(
2410                    "kept {} because deletion safety could not be rechecked: {}",
2411                    review_ref(number),
2412                    error.last_line()
2413                ),
2414            },
2415            Ok(false) => {}
2416            Err(error) => logdim!(
2417                "kept {} because removal did not reach a confirmed quiet point: {}",
2418                path.display(),
2419                error.last_line()
2420            ),
2421        }
2422    }
2423
2424    /// Release a read-only review checkout only when every observed part of
2425    /// its Git state still matches the checkpoint captured before the calls.
2426    pub(crate) fn release_review_worktree_checked(
2427        &self,
2428        number: i64,
2429        checkpoint: &WorktreeCheckpoint,
2430    ) -> Result<()> {
2431        let path = self.worktree_path(&format!("review-{number}"));
2432        self.require_unchanged_worktree(
2433            &path,
2434            checkpoint,
2435            &format!("review worktree for PR #{number}"),
2436        )?;
2437        if !self.review_ref_deletion_is_safe(number)? {
2438            bail!(
2439                "the review reference for PR #{number} has reflog-only recovery history. The \
2440                 worktree and reference were kept."
2441            );
2442        }
2443        if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
2444            bail!(
2445                "the verified review worktree at {} could not be removed, so its reference was \
2446                 kept",
2447                path.display()
2448            );
2449        }
2450        if !self.delete_review_ref_if_safe(number)? {
2451            bail!(
2452                "the review reference for PR #{number} changed before deletion. The reference was \
2453                 kept."
2454            );
2455        }
2456        Ok(())
2457    }
2458
2459    pub fn release_pr_worktree(&self, number: i64) -> bool {
2460        let path = self.worktree_path(&format!("pr-{number}"));
2461        let local = self.branch_for_pr(number);
2462        match self.branch_deletion_is_safe(&local) {
2463            Ok(true) => {}
2464            Ok(false) => {
2465                logdim!(
2466                    "kept {local} and {} because no surviving ref preserves its tip",
2467                    path.display()
2468                );
2469                return false;
2470            }
2471            Err(error) => {
2472                logdim!(
2473                    "kept {local} and {} because preservation could not be verified: {}",
2474                    path.display(),
2475                    error.last_line()
2476                );
2477                return false;
2478            }
2479        }
2480        match self.remove_worktree_at(&path) {
2481            Ok(true) => match self.delete_branch_if_safe(&local) {
2482                Ok(true) => {
2483                    self.forget_branch(&local);
2484                    true
2485                }
2486                Ok(false) => {
2487                    logdim!("kept {local} because its tip or reflog changed before deletion");
2488                    false
2489                }
2490                Err(error) => {
2491                    logdim!(
2492                        "kept {local} because deletion safety could not be rechecked: {}",
2493                        error.last_line()
2494                    );
2495                    false
2496                }
2497            },
2498            Ok(false) => false,
2499            Err(error) => {
2500                logdim!(
2501                    "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
2502                    path.display(),
2503                    error.last_line()
2504                );
2505                false
2506            }
2507        }
2508    }
2509
2510    // -- branch state -----------------------------------------------------
2511
2512    /// What to diff against: the remote tracking branch when it resolves, the
2513    /// local branch when it does not.
2514    ///
2515    /// This is not a nicety. Every "did the agent do anything" check hangs off
2516    /// this ref, and `git log` against a ref that does not exist fails silently
2517    /// and reads as "no commits". A checkout whose `origin/main` was never
2518    /// fetched would report every implementation as abandoned and throw the
2519    /// work away.
2520    pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
2521        let remote = format!("origin/{base}");
2522        if self.rev_exists(cwd, &remote) {
2523            return remote;
2524        }
2525        if self.rev_exists(cwd, base) {
2526            logdim!("origin/{base} does not resolve, comparing against local {base}");
2527            return base.to_string();
2528        }
2529        logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
2530        remote
2531    }
2532
2533    fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
2534        let spec = format!("{refname}^{{commit}}");
2535        !self
2536            .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
2537            .trim()
2538            .is_empty()
2539    }
2540
2541    pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
2542        let range = format!("{}..HEAD", self.base_ref(cwd, base));
2543        !self
2544            .git_try_at(Some(cwd), &["log", &range, "--oneline"])
2545            .trim()
2546            .is_empty()
2547    }
2548
2549    fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
2550        let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
2551        Ok(found.lines().any(|line| line.trim() == refname))
2552    }
2553
2554    fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
2555        let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
2556        count.trim().parse::<usize>().map_err(|e| {
2557            spar_err!(
2558                "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
2559                count.trim()
2560            )
2561        })
2562    }
2563
2564    pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
2565        let remote = format!("refs/remotes/origin/{base}");
2566        if self.exact_ref_exists_checked(cwd, &remote)? {
2567            return Ok(remote);
2568        }
2569        let local = format!("refs/heads/{base}");
2570        if self.exact_ref_exists_checked(cwd, &local)? {
2571            return Ok(local);
2572        }
2573        bail!("neither origin/{base} nor local branch {base} resolves")
2574    }
2575
2576    pub(crate) fn commit_count_checked(
2577        &self,
2578        cwd: &Path,
2579        refname: &str,
2580        base: &str,
2581    ) -> Result<usize> {
2582        let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
2583        let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
2584        count.trim().parse::<usize>().map_err(|e| {
2585            spar_err!(
2586                "git returned an invalid commit count for {range}: {:?} ({e})",
2587                count.trim()
2588            )
2589        })
2590    }
2591
2592    pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
2593        Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
2594    }
2595
2596    pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
2597        let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
2598        let head = head.trim().to_string();
2599        if head.is_empty() {
2600            bail!("git returned an empty HEAD for {}", cwd.display());
2601        }
2602        Ok(head)
2603    }
2604
2605    /// Whether a recorded SPAR branch's exact tip is retained by a pull request.
2606    ///
2607    /// Pull request head refs remain available after close or merge, so this is
2608    /// stronger than requiring an open pull request or a live remote branch.
2609    pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
2610        let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
2611        self.local_branch_is_preserved(branch.trim())
2612    }
2613
2614    /// Whether a recorded local branch's exact tip is retained by its pull
2615    /// request head, regardless of which branch is currently checked out.
2616    pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
2617        let known = self.known_branches();
2618        let Some(record) = known.get(branch) else {
2619            return Ok(false);
2620        };
2621        self.branch_is_preserved_checked(branch, record)
2622    }
2623
2624    /// Whether tracked, staged, or non-ignored untracked files are uncommitted.
2625    pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
2626        has_uncommitted_work(cwd)
2627    }
2628
2629    /// Whether removing a worktree would delete any local file Git does not
2630    /// reproduce from its commits, including ignored untracked files.
2631    fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
2632        repository_has_recoverable_work_with(cwd, true, disposable)
2633    }
2634
2635    /// Record ignored artifacts that existed before an editing call.
2636    pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
2637        let attributes = attribute_state(cwd)?;
2638        Ok(WorktreeBaseline {
2639            attributes,
2640            ignored_untracked: ignored_untracked_state(cwd)?,
2641            git_state: safe_git_state(cwd)?,
2642        })
2643    }
2644
2645    /// Capture the Git state of a checkout intended to remain read only while
2646    /// external commands inspect it.
2647    pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
2648        let attributes = attribute_state(cwd)?;
2649        Ok(WorktreeCheckpoint {
2650            path: std::fs::canonicalize(cwd)
2651                .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
2652            attributes,
2653            git_state: safe_git_state(cwd)?,
2654            ignored_untracked: ignored_untracked_state(cwd)?,
2655        })
2656    }
2657
2658    /// Require a read-only checkout to match a previously captured checkpoint.
2659    /// Any probe failure is an error because deletion cannot be proven safe.
2660    pub(crate) fn require_unchanged_worktree(
2661        &self,
2662        cwd: &Path,
2663        checkpoint: &WorktreeCheckpoint,
2664        label: &str,
2665    ) -> Result<()> {
2666        let resolved = std::fs::canonicalize(cwd).map_err(|e| {
2667            crate::error::SparError::uncertain_write(format!(
2668                "could not resolve the {label} at {} after inspection: {e}. It was kept.",
2669                cwd.display()
2670            ))
2671        })?;
2672        if resolved != checkpoint.path {
2673            return Err(uncertain_worktree_change(
2674                cwd,
2675                format!(
2676                    "the {label} moved from {} to {} during inspection. It was kept.",
2677                    checkpoint.path.display(),
2678                    resolved.display()
2679                ),
2680            ));
2681        }
2682        let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
2683            uncertain_worktree_change(
2684                cwd,
2685                format!(
2686                    "could not verify attribute files in the {label} at {}: {}. It was kept.",
2687                    cwd.display(),
2688                    e.last_line()
2689                ),
2690            )
2691        })?;
2692        if attributes != checkpoint.attributes {
2693            return Err(uncertain_worktree_change(
2694                cwd,
2695                format!(
2696                    "attribute files in the {label} at {} changed during inspection. It was \
2697                     kept for recovery.",
2698                    cwd.display()
2699                ),
2700            ));
2701        }
2702        let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
2703            uncertain_worktree_change(
2704                cwd,
2705                format!(
2706                    "could not verify the Git state of the {label} at {}: {}. It was kept.",
2707                    cwd.display(),
2708                    e.last_line()
2709                ),
2710            )
2711        })?;
2712        let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
2713            uncertain_worktree_change(
2714                cwd,
2715                format!(
2716                    "could not verify untracked files in the {label} at {}: {}. It was kept.",
2717                    cwd.display(),
2718                    e.last_line()
2719                ),
2720            )
2721        })?;
2722        let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
2723        // Said even when the inspection is accepted, for the same reason it is
2724        // said on an agent call: a path the project ignores is out of scope,
2725        // not unnoticed.
2726        note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
2727        let tracked = checkpoint.git_state.describe_difference(&git_state);
2728        let untracked = (!leavings.condemning.is_empty()).then(|| {
2729            format!(
2730                "untracked file(s) changed: {}",
2731                list_paths(&leavings.condemning)
2732            )
2733        });
2734        let Some(reason) = join_reasons(tracked, untracked) else {
2735            return Ok(());
2736        };
2737        Err(uncertain_worktree_change(
2738            cwd,
2739            format!(
2740                "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
2741                 for recovery.",
2742                cwd.display()
2743            ),
2744        ))
2745    }
2746
2747    /// Accept project-ignored files left by a successful editing call.
2748    /// Ordinary untracked files must already be represented by the commit.
2749    /// Acceptance does not authorize cleanup of the ignored files.
2750    pub(crate) fn refuse_new_ignored_files(
2751        &self,
2752        cwd: &Path,
2753        baseline: &WorktreeBaseline,
2754    ) -> Result<()> {
2755        self.allow_ignored_files(cwd, baseline).map(drop)
2756    }
2757
2758    /// Preserve unexpected output when the editing call did not finish.
2759    pub(crate) fn refuse_failed_edit_leavings(
2760        &self,
2761        cwd: &Path,
2762        baseline: &WorktreeBaseline,
2763    ) -> Result<()> {
2764        self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
2765            .map(drop)
2766    }
2767
2768    /// Reject ordinary untracked changes after a read-only call.
2769    pub(crate) fn refuse_read_only_leavings(
2770        &self,
2771        cwd: &Path,
2772        baseline: &WorktreeBaseline,
2773    ) -> Result<()> {
2774        self.refuse_changed_attributes(cwd, baseline)?;
2775        let after = ignored_untracked_state(cwd).map_err(|e| {
2776            uncertain_worktree_change(
2777                cwd,
2778                format!(
2779                    "could not verify untracked files in {} after a read-only call: {}. The \
2780                     worktree was kept for recovery.",
2781                    cwd.display(),
2782                    e.last_line()
2783                ),
2784            )
2785        })?;
2786        let leavings = baseline.ignored_untracked.read_only_leavings(&after);
2787        if leavings.condemning.is_empty() {
2788            return Ok(());
2789        }
2790        Err(uncertain_worktree_change(
2791            cwd,
2792            format!(
2793                "the read-only call left untracked file(s) in {} that the project does not \
2794                 ignore: {}. The worktree was kept for recovery.",
2795                cwd.display(),
2796                list_paths(&leavings.condemning)
2797            ),
2798        ))
2799    }
2800
2801    /// The ignored paths the check let through, for one report per attempt
2802    /// rather than one per check.
2803    fn allow_ignored_files(
2804        &self,
2805        cwd: &Path,
2806        baseline: &WorktreeBaseline,
2807    ) -> Result<AllowedArtifacts> {
2808        self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
2809    }
2810
2811    fn check_new_ignored_files(
2812        &self,
2813        cwd: &Path,
2814        baseline: &WorktreeBaseline,
2815        allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
2816    ) -> Result<AllowedArtifacts> {
2817        self.refuse_changed_attributes(cwd, baseline)?;
2818        let after = ignored_untracked_state(cwd).map_err(|e| {
2819            uncertain_worktree_change(
2820                cwd,
2821                format!(
2822                    "could not verify untracked files in {} after editing: {}. The worktree was \
2823                     kept for recovery.",
2824                    cwd.display(),
2825                    e.last_line()
2826                ),
2827            )
2828        })?;
2829        let changed = baseline.ignored_untracked.changed_paths(&after);
2830        if changed.is_empty() {
2831            return Ok(AllowedArtifacts::default());
2832        }
2833        let (ignored, changed): (Vec<_>, Vec<_>) = changed
2834            .into_iter()
2835            .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
2836        if changed.is_empty() {
2837            return Ok(AllowedArtifacts::split(ignored, &after));
2838        }
2839        let listed = list_paths(&changed);
2840        Err(uncertain_worktree_change(
2841            cwd,
2842            format!(
2843                "the editing call created or changed untracked or ignored file(s) in {} that \
2844                 cannot be represented by a managed commit: {listed}. The worktree was kept for \
2845                 recovery.",
2846                cwd.display()
2847            ),
2848        ))
2849    }
2850
2851    /// Existing untracked files belong to the checkout owner, even when a call
2852    /// also produces a valid tracked change. Refuse their modification or
2853    /// deletion before accepting the tracked result. Project-ignored files
2854    /// stay outside the commit, wherever the project puts them.
2855    pub(crate) fn refuse_changed_existing_untracked(
2856        &self,
2857        cwd: &Path,
2858        baseline: &WorktreeBaseline,
2859    ) -> Result<()> {
2860        self.check_changed_existing_untracked(cwd, baseline)
2861            .map(drop)
2862    }
2863
2864    fn allow_changed_ignored_artifacts(
2865        &self,
2866        cwd: &Path,
2867        baseline: &WorktreeBaseline,
2868    ) -> Result<AllowedArtifacts> {
2869        self.check_changed_existing_untracked(cwd, baseline)
2870    }
2871
2872    fn check_changed_existing_untracked(
2873        &self,
2874        cwd: &Path,
2875        baseline: &WorktreeBaseline,
2876    ) -> Result<AllowedArtifacts> {
2877        self.refuse_changed_attributes(cwd, baseline)?;
2878        let after = ignored_untracked_state(cwd).map_err(|e| {
2879            uncertain_worktree_change(
2880                cwd,
2881                format!(
2882                    "could not verify existing untracked files in {} after editing: {}. The \
2883                     worktree was kept for recovery.",
2884                    cwd.display(),
2885                    e.last_line()
2886                ),
2887            )
2888        })?;
2889        let changed = baseline.ignored_untracked.changed_existing_paths(&after);
2890        if changed.is_empty() {
2891            return Ok(AllowedArtifacts::default());
2892        }
2893        let (ignored, changed): (Vec<_>, Vec<_>) = changed
2894            .into_iter()
2895            .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
2896        if changed.is_empty() {
2897            return Ok(AllowedArtifacts::split(ignored, &after));
2898        }
2899        let listed = list_paths(&changed);
2900        Err(uncertain_worktree_change(
2901            cwd,
2902            format!(
2903                "the editing call changed or deleted existing untracked file(s) in {}: \
2904                 {listed}. The worktree was kept for recovery.",
2905                cwd.display()
2906            ),
2907        ))
2908    }
2909
2910    /// Refuse a byte or mode change that the index did not represent.
2911    ///
2912    /// Clean filters can normalize a working file back to its existing blob,
2913    /// and index flags can hide a change from porcelain status. Comparing the
2914    /// actual tracked files on both sides keeps those bytes from being treated
2915    /// as disposable just because Git has no diff for them.
2916    pub(crate) fn refuse_unrepresented_tracked_changes(
2917        &self,
2918        cwd: &Path,
2919        baseline: &WorktreeBaseline,
2920    ) -> Result<()> {
2921        self.refuse_changed_attributes(cwd, baseline)?;
2922        let after = safe_git_state(cwd).map_err(|e| {
2923            uncertain_worktree_change(
2924                cwd,
2925                format!(
2926                    "could not verify tracked files in {} after editing: {}. The worktree was \
2927                     kept for recovery.",
2928                    cwd.display(),
2929                    e.last_line()
2930                ),
2931            )
2932        })?;
2933        let mut changed = Vec::new();
2934        let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
2935            uncertain_worktree_change(
2936                cwd,
2937                format!(
2938                    "could not record untracked files before verifying transformed content in {}: \
2939                     {}. The worktree was kept for recovery.",
2940                    cwd.display(),
2941                    e.last_line()
2942                ),
2943            )
2944        })?;
2945        let mut filter_was_run = false;
2946        let mut filter_problem = None;
2947        let mut repositories: BTreeSet<PathBuf> =
2948            baseline.git_state.repositories.keys().cloned().collect();
2949        repositories.extend(after.repositories.keys().cloned());
2950        'repositories: for repository_path in repositories {
2951            let before_repository = baseline.git_state.repositories.get(&repository_path);
2952            let after_repository = after.repositories.get(&repository_path);
2953            if before_repository.is_none() || after_repository.is_none() {
2954                changed.push(repository_path.clone());
2955                continue;
2956            }
2957            if before_repository.map(|repository| &repository.gitlinks)
2958                != after_repository.map(|repository| &repository.gitlinks)
2959            {
2960                changed.push(repository_path.join("<gitlinks>"));
2961            }
2962            let mut paths = BTreeSet::new();
2963            if let Some(repository) = before_repository {
2964                paths.extend(repository.tracked.keys().cloned());
2965            }
2966            if let Some(repository) = after_repository {
2967                paths.extend(repository.tracked.keys().cloned());
2968            }
2969            for path in paths {
2970                let before = before_repository.and_then(|repository| repository.tracked.get(&path));
2971                let current = after_repository.and_then(|repository| repository.tracked.get(&path));
2972                let worktree_changed =
2973                    before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
2974                let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
2975                    != current.map(|entry| (&entry.index_mode, &entry.index_oid));
2976                if !worktree_changed {
2977                    continue;
2978                }
2979                if !index_changed {
2980                    changed.push(repository_path.join(&path));
2981                    continue;
2982                }
2983                let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
2984                let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
2985                let Some(current_entry) = current else {
2986                    continue;
2987                };
2988                let Some(current_worktree) = current_worktree else {
2989                    continue;
2990                };
2991                let content_changed =
2992                    before_worktree.map(|file| file.content) != Some(current_worktree.content);
2993                let mode_changed = before_worktree.map(|file| file.mode.as_str())
2994                    != Some(current_worktree.mode.as_str());
2995                let repository = cwd.join(&repository_path);
2996                let represented_content = if content_changed {
2997                    filter_was_run = true;
2998                    let result =
2999                        filtered_index_content(&repository, &path, &current_entry.index_oid);
3000                    self.refuse_changed_attributes(cwd, baseline)?;
3001                    match result {
3002                        Ok(expected) => expected == current_worktree.content,
3003                        Err(error) => {
3004                            filter_problem = Some(format!(
3005                                "could not verify transformed content for {:?}: {}",
3006                                repository_path.join(&path),
3007                                error.last_line()
3008                            ));
3009                            false
3010                        }
3011                    }
3012                } else {
3013                    true
3014                };
3015                let represented_mode =
3016                    !mode_changed || current_worktree.mode == current_entry.index_mode;
3017                if !represented_content || !represented_mode {
3018                    changed.push(repository_path.join(&path));
3019                }
3020                if filter_problem.is_some() {
3021                    break 'repositories;
3022                }
3023            }
3024        }
3025        if filter_was_run {
3026            self.refuse_changed_attributes(cwd, baseline)?;
3027            let verified = safe_git_state(cwd).map_err(|e| {
3028                uncertain_worktree_change(
3029                    cwd,
3030                    format!(
3031                        "could not recheck tracked files after verifying transformed content in \
3032                         {}: {}. The worktree was kept for recovery.",
3033                        cwd.display(),
3034                        e.last_line()
3035                    ),
3036                )
3037            })?;
3038            let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3039                uncertain_worktree_change(
3040                    cwd,
3041                    format!(
3042                        "could not recheck untracked files after verifying transformed content \
3043                         in {}: {}. The worktree was kept for recovery.",
3044                        cwd.display(),
3045                        e.last_line()
3046                    ),
3047                )
3048            })?;
3049            if verified != after
3050                || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3051            {
3052                return Err(uncertain_worktree_change(
3053                    cwd,
3054                    "a content filter changed the worktree while SPAR verified the managed \
3055                     commit. The worktree was kept for recovery.",
3056                ));
3057            }
3058            self.refuse_changed_existing_untracked(cwd, baseline)?;
3059        }
3060        if let Some(problem) = filter_problem {
3061            return Err(uncertain_worktree_change(
3062                cwd,
3063                format!("{problem}. The worktree was kept for recovery."),
3064            ));
3065        }
3066        if changed.is_empty() {
3067            return Ok(());
3068        }
3069        let listed = list_paths(&changed);
3070        Err(uncertain_worktree_change(
3071            cwd,
3072            format!(
3073                "the editing call changed tracked working-file bytes, modes, repositories, or \
3074                 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3075                 recovery."
3076            ),
3077        ))
3078    }
3079
3080    pub(crate) fn refuse_changed_attributes(
3081        &self,
3082        cwd: &Path,
3083        baseline: &WorktreeBaseline,
3084    ) -> Result<()> {
3085        let after = attribute_state(cwd).map_err(|e| {
3086            uncertain_worktree_change(
3087                cwd,
3088                format!(
3089                    "could not verify attribute files in {} after editing: {}. The worktree was \
3090                     kept for recovery.",
3091                    cwd.display(),
3092                    e.last_line()
3093                ),
3094            )
3095        })?;
3096        if after == baseline.attributes {
3097            return Ok(());
3098        }
3099        Err(uncertain_worktree_change(
3100            cwd,
3101            format!(
3102                "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
3103                 refused to run a Git operation that could select a new external filter.",
3104                cwd.display()
3105            ),
3106        ))
3107    }
3108
3109    /// Commit a successful editing call from the trusted harness process.
3110    ///
3111    /// Editing sandboxes only need the working tree. They never need writable
3112    /// access to the repository's object database, refs, config, or hooks.
3113    pub(crate) fn commit_pending_changes(
3114        &self,
3115        cwd: &Path,
3116        baseline: &WorktreeBaseline,
3117        preferred_subject: &str,
3118        fallback_subject: &str,
3119    ) -> Result<bool> {
3120        let mut artifacts = IgnoredArtifacts::default();
3121        self.refuse_changed_attributes(cwd, baseline)?;
3122        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3123        refuse_unsafe_index_flags(cwd)?;
3124        if !self.has_uncommitted_changes(cwd)? {
3125            artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3126            artifacts.report(cwd);
3127            return Ok(false);
3128        }
3129        self.stage_managed_changes(cwd, baseline).map_err(|e| {
3130            e.with_message(format!(
3131                "could not stage changes in {}: {}",
3132                cwd.display(),
3133                e.last_line()
3134            ))
3135        })?;
3136        // Git ignore rules decide which files stay outside the commit.
3137        // Whether cleanup can delete them is a separate decision.
3138        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3139        let changed_gitlinks = changed_staged_gitlinks(cwd)?;
3140        if !changed_gitlinks.is_empty() {
3141            let listed = list_paths(&changed_gitlinks);
3142            bail!(
3143                "the editing call added or changed a gitlink at {listed}. It was staged but not \
3144                 committed because the referenced repository objects might exist only inside \
3145                 this worktree. The worktree was kept for recovery."
3146            );
3147        }
3148        let mut subject = self.clean_title(preferred_subject)?;
3149        if subject.trim().is_empty() {
3150            subject = self.clean_title(fallback_subject)?;
3151        }
3152        self.commit_staged_changes(cwd, &subject).map_err(|e| {
3153            e.with_message(format!(
3154                "could not commit changes in {}: {}. The staged files were kept.",
3155                cwd.display(),
3156                e.last_line()
3157            ))
3158        })?;
3159        if has_tracked_or_staged_work(cwd)? {
3160            bail!(
3161                "the commit in {} left additional uncommitted files. They were kept for \
3162                 recovery.",
3163                cwd.display()
3164            );
3165        }
3166        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3167        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3168        artifacts.report(cwd);
3169        Ok(true)
3170    }
3171
3172    fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
3173        let after = ignored_untracked_state(cwd)?;
3174        self.git_at_without_automation(cwd, &["add", "-u"])?;
3175        let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
3176        if paths.is_empty() {
3177            return Ok(());
3178        }
3179        let mut input = Vec::new();
3180        for path in paths {
3181            input.extend(os_str_bytes(path.as_os_str())?);
3182            input.push(0);
3183        }
3184        let argv = git_without_automation_argv(&[
3185            "--literal-pathspecs",
3186            "add",
3187            "--pathspec-from-file=-",
3188            "--pathspec-file-nul",
3189        ]);
3190        proc::run_with_input_bytes(
3191            &argv,
3192            &self.git_opts(Some(cwd), true).stop_descendants(true),
3193            &input,
3194        )?;
3195        Ok(())
3196    }
3197
3198    /// Commit an index prepared by the parent without signing, hooks, or
3199    /// inherited repository automation.
3200    pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
3201        self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
3202            .map(|_| ())
3203    }
3204
3205    /// How many commits `refname` carries that the base does not.
3206    ///
3207    /// Counted from the commits themselves rather than from `commit_subjects`,
3208    /// which drops a commit whose message is empty. The guards in
3209    /// `worktree_add` decide whether to delete a branch on this number, and an
3210    /// empty message must not read as an empty branch.
3211    pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
3212        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3213        self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
3214            .trim()
3215            .parse()
3216            .unwrap_or(0)
3217    }
3218
3219    /// One `hash subject` line per commit `refname` carries that the base does
3220    /// not, oldest first. For showing a person what is on a branch, so the
3221    /// hash keeps a commit with no message from listing as nothing.
3222    pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3223        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3224        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3225            .lines()
3226            .map(str::to_string)
3227            .collect()
3228    }
3229
3230    /// The commits `later` carries that `earlier` does not, oldest first, when
3231    /// `earlier` is genuinely behind it.
3232    ///
3233    /// `None` when it is not an ancestor, which is not the same as nothing
3234    /// having landed. `rewrite_commits_if_needed` rewrites hashes from the first
3235    /// offending commit spar made onward, so a head recorded part way through a
3236    /// round can still be a readable object and no longer be on the branch. `git log` answers that
3237    /// with every commit on the branch, so without the check the one caller
3238    /// would report the whole branch as unread, which is the widest possible
3239    /// wrong answer.
3240    ///
3241    /// No `base_ref` resolution, unlike its neighbours: these are commits rather
3242    /// than branch names, and putting a sha through it logs a fallback line
3243    /// every time.
3244    pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3245        let ancestor = self
3246            .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3247            .is_ok();
3248        if !ancestor {
3249            return None;
3250        }
3251        let range = format!("{earlier}..{later}");
3252        Some(
3253            self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3254                .lines()
3255                .map(str::to_string)
3256                .collect(),
3257        )
3258    }
3259
3260    /// The subjects of the commits `refname` carries that the base does not,
3261    /// oldest first.
3262    pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3263        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3264        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3265            .lines()
3266            .map(str::trim)
3267            .filter(|line| !line.is_empty())
3268            .map(str::to_string)
3269            .collect()
3270    }
3271
3272    /// The paths this checkout changes relative to the base, sorted.
3273    ///
3274    /// A three dot range, matching `diff_stat`: what the branch did, not what
3275    /// the base has done since.
3276    ///
3277    /// `--no-renames` because a rename reported as its destination alone leaves
3278    /// the source out of the list, and a part carrying only the destination
3279    /// would be a copy. As a deletion and an addition it is two paths, which a
3280    /// part can carry together or leave to the leftover report.
3281    ///
3282    /// `-z` because without it git writes paths for display: anything
3283    /// non-ASCII comes back escaped and wrapped in quotes, and that string is
3284    /// not a path. A part built from one carries a pathspec matching no file,
3285    /// so the file never reaches the slice while every list still says the part
3286    /// took it. It also keeps a path with a space at either end intact.
3287    pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
3288        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3289        self.git_try_at(
3290            Some(cwd),
3291            &["diff", "--name-only", "--no-renames", "-z", &range],
3292        )
3293        .split('\0')
3294        .filter(|path| !path.is_empty())
3295        .map(str::to_string)
3296        .collect()
3297    }
3298
3299    /// The files one commit range touched, for checking a claim against the
3300    /// diff rather than against the report that made it.
3301    pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
3302        self.git_try_at(
3303            Some(cwd),
3304            &[
3305                "diff",
3306                "--name-only",
3307                "--no-renames",
3308                "-z",
3309                &format!("{from}..{to}"),
3310            ],
3311        )
3312        .split('\0')
3313        .filter(|path| !path.is_empty())
3314        .map(str::to_string)
3315        .collect()
3316    }
3317
3318    /// Where `refname` left the base: the commit its own change is measured
3319    /// from, and the one a slice of that change has to be taken against.
3320    pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
3321        let base_ref = self.base_ref(cwd, base);
3322        let out = self
3323            .git_at(Some(cwd), &["merge-base", &base_ref, refname])
3324            .map_err(|e| {
3325                spar_err!(
3326                    "could not find where {refname} and {base_ref} diverged. {}",
3327                    e.last_line()
3328                )
3329            })?;
3330        let sha = out.trim().to_string();
3331        if sha.is_empty() {
3332            bail!("{refname} and {base_ref} share no history");
3333        }
3334        Ok(sha)
3335    }
3336
3337    pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
3338        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3339        let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
3340        full.trim().to_string()
3341    }
3342
3343    /// Scrub commit messages that slipped past the prompt.
3344    ///
3345    /// `git filter-branch` calls back into this same binary, so there is no
3346    /// interpreter to find and no second copy of the rules to drift.
3347    ///
3348    /// `floor` is the head spar found when it started, and nothing at or below
3349    /// it is rewritten. The style gate is for what spar writes, and a person's
3350    /// commit message is not that: rewriting one changes a published SHA, which
3351    /// diverges the branch under somebody's own checkout and is exactly the
3352    /// history rewrite this design promises never to do. A `None` floor means
3353    /// every commit above the base is spar's own, which is true of a branch
3354    /// spar created in this invocation and of nothing else.
3355    pub fn rewrite_commits_if_needed(
3356        &self,
3357        cwd: &Path,
3358        base: &str,
3359        floor: Option<&str>,
3360    ) -> Result<()> {
3361        let base_ref = self.base_ref(cwd, base);
3362        let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
3363        self.report_commits_left_alone(cwd, &base_ref, floor);
3364
3365        let range = format!("{floor}..HEAD");
3366        let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
3367
3368        let offenders = raw
3369            .split('\x1e')
3370            .filter_map(|entry| entry.split_once('\0'))
3371            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3372            .count();
3373        if offenders == 0 {
3374            return Ok(());
3375        }
3376        logdim!("{offenders} commit message(s) violated style rules, rewriting");
3377
3378        let exe = self_binary()?;
3379        let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
3380
3381        let argv: Vec<String> = [
3382            "git",
3383            "filter-branch",
3384            "-f",
3385            "--msg-filter",
3386            &filter,
3387            &range,
3388        ]
3389        .iter()
3390        .map(|s| s.to_string())
3391        .collect();
3392        let opts = ExecOpts::new()
3393            .cwd(cwd)
3394            .check(false)
3395            .timeout_secs(600)
3396            .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
3397            .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
3398            .env(
3399                "SPAR_BAN_AI_ATTRIBUTION",
3400                bool_env(self.style.ban_ai_attribution),
3401            );
3402        let _ = proc::run(&argv, &opts);
3403
3404        let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
3405        if !style::violations(&after, &self.style).is_empty() {
3406            bail!(
3407                "commit messages still violate style rules after a rewrite in {}.",
3408                cwd.display()
3409            );
3410        }
3411        Ok(())
3412    }
3413
3414    /// Say out loud when a message spar will not touch breaks the style rules.
3415    ///
3416    /// Silence here would read as "there was nothing to scrub", and the point
3417    /// of the floor is that there was something and spar chose to leave it.
3418    fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
3419        if floor == base_ref {
3420            return;
3421        }
3422        let raw = self.git_try_at(
3423            Some(cwd),
3424            &[
3425                "log",
3426                &format!("{base_ref}..{floor}"),
3427                "--format=%H%x00%B%x1e",
3428            ],
3429        );
3430        let left: Vec<&str> = raw
3431            .split('\x1e')
3432            .filter_map(|entry| entry.split_once('\0'))
3433            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3434            .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
3435            .collect();
3436        if left.is_empty() {
3437            return;
3438        }
3439        logdim!(
3440            "{} commit message(s) already on the branch break the style rules and were left \
3441             alone: {}. spar does not rewrite commits it did not write.",
3442            left.len(),
3443            left.join(", ")
3444        );
3445    }
3446
3447    /// Push by explicit refspec from HEAD, leasing the head spar reviewed.
3448    ///
3449    /// A resumed PR is checked out under a local name (`pr-N`) that does not
3450    /// match its remote branch, so pushing by branch name would resolve the
3451    /// wrong local ref or fail outright.
3452    ///
3453    /// `expected` is the remote head spar last read. A bare `--force-with-lease`
3454    /// leases the implicit tracking ref instead, which is shared across every
3455    /// worktree and is updated by any `git fetch`. The editing agent is a real
3456    /// CLI with shell access, and fetching is a normal thing for it to do: one
3457    /// `git fetch` mid round moves the tracking ref to whatever the remote holds
3458    /// and the lease then passes against a head spar never reviewed, which
3459    /// silently drops somebody's commit. Naming the head makes the push refuse
3460    /// exactly that case.
3461    pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
3462        let expected = expected.map(str::trim).filter(|head| !head.is_empty());
3463        if let Some(head) = expected {
3464            self.refuse_push_that_drops(cwd, head)?;
3465        }
3466        let refspec = format!("HEAD:{branch}");
3467        let lease = match expected {
3468            Some(head) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
3469            None => "--force-with-lease".to_string(),
3470        };
3471        let pushed = self
3472            .git_at(Some(cwd), &["push", &lease, "origin", &refspec])
3473            .map(|_| ())
3474            .map_err(|e| {
3475                spar_err!(
3476                    "could not push to origin/{branch}. {}\nCheck push access and whether the \
3477                     branch moved under you.",
3478                    e.last_line()
3479                )
3480            });
3481        self.record_write(pushed)
3482    }
3483
3484    /// Refuse a push whose HEAD does not contain the head spar reviewed.
3485    ///
3486    /// The lease answers the remote moving. This answers the local side moving:
3487    /// an agent that reset or rebased the worktree leaves a HEAD that would
3488    /// publish over the reviewed head rather than on top of it, and the lease
3489    /// cannot tell the difference because the remote is exactly where spar left
3490    /// it.
3491    fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
3492        let head = self.head_oid_checked(cwd)?;
3493        if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
3494            return Ok(());
3495        }
3496        bail!(
3497            "refusing to push {head} because it does not contain the reviewed head {expected}. \
3498             The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
3499             the intended commits on top of it before resuming.",
3500            cwd.display()
3501        )
3502    }
3503
3504    /// Create one remote branch for a split without ever moving an existing ref.
3505    ///
3506    /// `worktree_for_split` chooses a name that is free locally and on origin,
3507    /// but another writer can still take it before the push. An empty expected
3508    /// value in the lease makes this an atomic create: it creates an absent ref,
3509    /// accepts an identical ref as a no-op, and never moves an existing ref.
3510    /// The shared `push` method cannot be used because its lease permits
3511    /// updating a ref fetched earlier.
3512    pub fn push_split_branch(
3513        &self,
3514        cwd: &Path,
3515        branch: &str,
3516    ) -> std::result::Result<(), SplitPushError> {
3517        let remote_ref = format!("refs/heads/{branch}");
3518        let lease = format!("--force-with-lease={remote_ref}:");
3519        let refspec = format!("HEAD:{remote_ref}");
3520        let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
3521            Ok(_) => Ok(()),
3522            Err(push_error) => {
3523                let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
3524                let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
3525                reconcile_failed_split_push(branch, push_error, local, remote)
3526            }
3527        };
3528        self.record_write(result)
3529    }
3530
3531    // -- gh ---------------------------------------------------------------
3532
3533    pub fn gh(&self, args: &[&str]) -> Result<String> {
3534        self.gh_at(None, args)
3535    }
3536
3537    pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
3538        let mut argv = vec!["gh".to_string()];
3539        argv.extend(args.iter().map(|s| s.to_string()));
3540        proc::run(
3541            &argv,
3542            &ExecOpts::new()
3543                .cwd(cwd.unwrap_or(&self.root))
3544                .timeout_secs(300),
3545        )
3546    }
3547
3548    /// Run gh with something on its stdin.
3549    ///
3550    /// A tracker body is far too long to pass on argv, and `--body-file -` is
3551    /// how gh takes one. `proc::exec` already wires the pipe, so this is a
3552    /// sibling of `gh_at` rather than anything new.
3553    pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
3554        let mut argv = vec!["gh".to_string()];
3555        argv.extend(args.iter().map(|s| s.to_string()));
3556        proc::run(
3557            &argv,
3558            &ExecOpts::new()
3559                .cwd(&self.root)
3560                .timeout_secs(300)
3561                .stdin(stdin),
3562        )
3563    }
3564
3565    pub fn gh_try(&self, args: &[&str]) -> String {
3566        let mut argv = vec!["gh".to_string()];
3567        argv.extend(args.iter().map(|s| s.to_string()));
3568        proc::run(
3569            &argv,
3570            &ExecOpts::new()
3571                .cwd(&self.root)
3572                .check(false)
3573                .timeout_secs(300),
3574        )
3575        .unwrap_or_default()
3576    }
3577
3578    /// The login `gh` is authenticated as.
3579    ///
3580    /// A hard error, never a degradation. Everything spar wrote has to be
3581    /// excluded from what it answers, and custody cannot be read from git
3582    /// authorship, so this is the only thing that tells spar's own comments
3583    /// from somebody else's. Without it the failure is not "answers a bit too
3584    /// much", it is a thread where spar answers itself until somebody notices.
3585    ///
3586    /// Not cached on disk: `gh auth switch` between runs would make a stored
3587    /// answer wrong in exactly the way that produces that thread.
3588    pub fn viewer_login(&self) -> Result<&str> {
3589        if let Some(login) = self.viewer.get() {
3590            return Ok(login);
3591        }
3592        let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
3593        let login = if !rest.trim().is_empty() {
3594            rest.trim().to_string()
3595        } else {
3596            // A token that cannot read /user can still answer for itself in
3597            // GraphQL, which is the case on some Enterprise installs.
3598            self.gh(&[
3599                "api",
3600                "graphql",
3601                "-f",
3602                "query={ viewer { login } }",
3603                "--jq",
3604                ".data.viewer.login",
3605            ])
3606            .map_err(|e| {
3607                spar_err!(
3608                    "could not find out who `gh` is authenticated as, so spar cannot tell its \
3609                     own comments from anybody else's. {}\nRun `gh auth status`.",
3610                    e.last_line()
3611                )
3612            })?
3613            .trim()
3614            .to_string()
3615        };
3616        if login.is_empty() {
3617            bail!("`gh` reported an empty login. Run `gh auth status`.");
3618        }
3619        Ok(self.viewer.get_or_init(|| login))
3620    }
3621
3622    /// One issue as it stands, open or closed.
3623    ///
3624    /// `fetch_issues` reads a queue to work: it drops a closed issue and fails
3625    /// when nothing survives. Both are wrong for reading one issue back, where
3626    /// closed is an answer and the empty case cannot arise.
3627    pub fn read_issue(&self, number: i64) -> Result<Issue> {
3628        let text = self
3629            .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
3630            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3631        serde_json::from_str(&text)
3632            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
3633    }
3634
3635    pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
3636        let mut issues = Vec::new();
3637        for number in numbers {
3638            let text = self
3639                .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
3640                .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3641            let issue: Issue = serde_json::from_str(&text)
3642                .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3643            if issue.is_closed() {
3644                crate::log!("issue #{number} is closed, skipping");
3645                continue;
3646            }
3647            issues.push(issue);
3648        }
3649        if issues.is_empty() {
3650            bail!("no open issues to work on");
3651        }
3652        Ok(issues)
3653    }
3654
3655    /// Open items, lowest numbered first, from `min_number` upward.
3656    ///
3657    /// The floor exists because a long lived repository accumulates a tail of
3658    /// old issues nobody is going to get to, and taking the lowest numbered
3659    /// open items means walking straight into them.
3660    fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3661        #[derive(Deserialize)]
3662        struct Row {
3663            number: i64,
3664        }
3665        let text = self.gh(&[
3666            kind,
3667            "list",
3668            "--state",
3669            "open",
3670            "--limit",
3671            &FETCH_CEILING.to_string(),
3672            "--json",
3673            "number",
3674        ])?;
3675        let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
3676        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
3677        numbers.sort_unstable();
3678
3679        let noun = if kind == "issue" { "issues" } else { "PRs" };
3680        let found = numbers.len();
3681        if min_number > 0 {
3682            numbers.retain(|n| *n >= min_number);
3683            let skipped = found - numbers.len();
3684            if skipped > 0 {
3685                crate::log!("{skipped} open {noun} below #{min_number} skipped");
3686            }
3687        }
3688        if found >= FETCH_CEILING {
3689            crate::log!(
3690                "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
3691                 considered."
3692            );
3693        }
3694        if numbers.len() > limit {
3695            crate::log!(
3696                "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
3697                 explicitly.",
3698                numbers.len()
3699            );
3700            numbers.truncate(limit);
3701        }
3702        Ok(numbers)
3703    }
3704
3705    /// Open issues, lowest numbered first. `gh issue list` excludes PRs.
3706    pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3707        self.open_numbers("issue", limit, min_number)
3708    }
3709
3710    pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3711        self.open_numbers("pr", limit, min_number)
3712    }
3713
3714    pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
3715        self.branch_prs(branch, "open").into_iter().next()
3716    }
3717
3718    /// The open pull request for a branch, preserving a failed lookup as an
3719    /// error when the caller is deciding whether a write already landed.
3720    pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
3721        let text = self.gh(&[
3722            "pr",
3723            "list",
3724            "--head",
3725            branch,
3726            "--base",
3727            base,
3728            "--state",
3729            "open",
3730            "--json",
3731            "number,url,title,baseRefName",
3732        ])?;
3733        pr_for_base(&text, branch, base)
3734    }
3735
3736    /// Every pull request opened from this branch, merged and closed ones
3737    /// included, because a commit is preserved by whichever one carries it and
3738    /// that is rarely the newest.
3739    fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
3740        self.branch_prs(branch, "all")
3741    }
3742
3743    fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
3744        let text = self.gh_try(&[
3745            "pr",
3746            "list",
3747            "--head",
3748            branch,
3749            "--state",
3750            state,
3751            "--json",
3752            "number,url,title",
3753        ]);
3754        serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
3755    }
3756
3757    /// Whether a number names an issue or a pull request.
3758    ///
3759    /// `gh issue view` happily returns a pull request when handed its number,
3760    /// so it cannot be used to tell them apart. The issues API carries both and
3761    /// marks a pull request with a `pull_request` key, which is definitive.
3762    pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
3763        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
3764        let text = self
3765            .gh(&[
3766                "api",
3767                &path,
3768                "--jq",
3769                "if .pull_request then \"pr\" else \"issue\" end",
3770            ])
3771            .map_err(|e| {
3772                spar_err!(
3773                    "no issue or pull request #{number} in this repository. {}",
3774                    e.last_line()
3775                )
3776            })?;
3777        match text.trim() {
3778            "pr" => Ok(ItemKind::Pr),
3779            "issue" => Ok(ItemKind::Issue),
3780            other => Err(spar_err!(
3781                "could not tell whether #{number} is an issue or a pull request (got {other:?})"
3782            )),
3783        }
3784    }
3785
3786    /// An open pull request that would close this issue, whoever opened it.
3787    ///
3788    /// spar's own branch naming is checked first because it is exact and cheap.
3789    /// Falling back to GitHub's own issue linkage is what lets spar pick up a
3790    /// pull request a person started on a branch named anything at all.
3791    ///
3792    /// An error rather than `None` when the question could not be asked. The
3793    /// two answers are not interchangeable: "no pull request exists" is the one
3794    /// that makes spar implement over the top of somebody's work and open a
3795    /// second pull request for the same issue.
3796    pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
3797        let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
3798        if let Some(pr) = mine {
3799            return Ok(Some(pr));
3800        }
3801        let listed = match self.gh(&[
3802            "pr",
3803            "list",
3804            "--state",
3805            "open",
3806            "--limit",
3807            &FETCH_CEILING.to_string(),
3808            "--json",
3809            "number,url,title,baseRefName,closingIssuesReferences",
3810        ]) {
3811            Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
3812                spar_err!("could not read the pull request list while looking for #{issue}")
3813            })?,
3814            Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
3815        };
3816        if listed.len() >= FETCH_CEILING {
3817            logwarn!(
3818                "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a                  pull request past it cannot be seen from here."
3819            );
3820        }
3821        let default = self.default_branch(base);
3822        if !base.eq_ignore_ascii_case(&default) {
3823            // GitHub populates closingIssuesReferences only for pull requests
3824            // targeting the default branch, so on any other base the linkage is
3825            // silence rather than an answer.
3826            logwarn!(
3827                "#{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."
3828            );
3829        }
3830        Ok(pick_linked_pr(&listed, issue))
3831    }
3832
3833    /// spar's own branch, with a failed lookup kept as a failure.
3834    fn pr_for_branch_failing_closed(
3835        &self,
3836        branch: &str,
3837        base: &str,
3838        issue: i64,
3839    ) -> Result<Option<PrRef>> {
3840        match self.try_pr_for_branch(branch, base) {
3841            Ok(found) => Ok(found),
3842            Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
3843        }
3844    }
3845
3846    /// What to do when GitHub would not say whether a pull request exists.
3847    ///
3848    /// An error, because "no pull request exists" is the answer that makes spar
3849    /// implement from scratch and open a second pull request over somebody's
3850    /// work. The one exception is a repository gh cannot reach at all: there is
3851    /// no pull request to collide with when there is no GitHub repository in
3852    /// play, and every later write would fail anyway. That case is a warning and
3853    /// a `None`, so a run against a plain git remote behaves as it always did.
3854    fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
3855        if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
3856            return Err(spar_err!(
3857                "could not check whether a pull request already exists for #{issue}: {}. \
3858                 Refusing to implement over the top of work that may already be open.",
3859                e.last_line()
3860            ));
3861        }
3862        logwarn!(
3863            "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
3864             whether a pull request already exists for it.",
3865            e.last_line()
3866        );
3867        Ok(())
3868    }
3869
3870    pub fn pr_view(&self, number: i64) -> Result<PrView> {
3871        let text = self.gh(&[
3872            "pr",
3873            "view",
3874            &number.to_string(),
3875            "--json",
3876            "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
3877        ])?;
3878        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
3879    }
3880
3881    fn try_pr_state(&self, number: i64) -> Result<String> {
3882        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
3883        serde_json::from_str::<Value>(text.trim())
3884            .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
3885            .get("state")
3886            .and_then(Value::as_str)
3887            .map(str::to_string)
3888            .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
3889    }
3890
3891    pub fn pr_state(&self, number: i64) -> String {
3892        self.try_pr_state(number).unwrap_or_default()
3893    }
3894
3895    /// The commit currently exposed as a pull request's head.
3896    pub fn pr_head_oid(&self, number: i64) -> Result<String> {
3897        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
3898        let oid = serde_json::from_str::<Value>(&text)
3899            .ok()
3900            .and_then(|value| {
3901                value
3902                    .get("headRefOid")
3903                    .and_then(Value::as_str)
3904                    .map(str::trim)
3905                    .filter(|oid| !oid.is_empty())
3906                    .map(str::to_string)
3907            })
3908            .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
3909        Ok(oid)
3910    }
3911
3912    pub fn create_pr(
3913        &self,
3914        cwd: &Path,
3915        branch: &str,
3916        base: &str,
3917        title: &str,
3918        body: &str,
3919    ) -> Result<PrRef> {
3920        let title = self.record_failed_write(self.clean_title(title))?;
3921        let body = self.record_failed_write(self.clean(body))?;
3922        let mut argv = vec![
3923            "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
3924        ];
3925        if self.drafts != Drafts::Never {
3926            argv.push("--draft");
3927        }
3928        let created = self.gh_at(Some(cwd), &argv);
3929        let found = self.try_pr_for_branch(branch, base);
3930        self.record_write(reconcile_pr_creation(branch, created, found))
3931    }
3932
3933    pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
3934        let body = signed(&self.record_failed_write(self.clean(body))?);
3935        let comments = self.record_failed_write(self.try_issue_comments(number))?;
3936        if has_exact_comment(&comments, &body) {
3937            return Ok(());
3938        }
3939        let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
3940        let result = match posted {
3941            Ok(_) => Ok(()),
3942            Err(post_error) => {
3943                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3944            }
3945        };
3946        self.record_write(result)
3947    }
3948
3949    pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
3950        let body = signed(&self.record_failed_write(self.clean(body))?);
3951        let comments = self.record_failed_write(self.try_issue_comments(number))?;
3952        if has_exact_comment(&comments, &body) {
3953            return Ok(());
3954        }
3955        let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
3956        let result = match posted {
3957            Ok(_) => Ok(()),
3958            Err(post_error) => {
3959                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3960            }
3961        };
3962        self.record_write(result)
3963    }
3964
3965    /// Comment, then close as not planned.
3966    ///
3967    /// Only ever called when both agents independently declined the issue: one
3968    /// agent's opinion is not enough to close somebody's report.
3969    pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
3970        self.comment_issue(number, body)?;
3971        let n = number.to_string();
3972        let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
3973            Ok(_) => Ok(()),
3974            // Older gh builds do not take --reason.
3975            Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
3976                spar_err!(
3977                    "commented on #{number} but could not close it: {}",
3978                    e.last_line()
3979                )
3980            }),
3981        };
3982        self.record_write(closed)
3983    }
3984
3985    /// Replace an issue body, refusing unless it is still byte for byte what
3986    /// the caller read and validating only the fragment spar inserted.
3987    ///
3988    /// The only place spar rewrites text somebody else wrote, so the check is
3989    /// the whole point: an edit computed from a body that has since moved would
3990    /// silently delete whatever moved it. The caller decides whether another
3991    /// attempt is safe for its workflow.
3992    ///
3993    /// Deliberately not through `clean_issue_body`. The body is mostly a
3994    /// person's own prose, and the scrub would rewrite their punctuation while
3995    /// the length budget could truncate the end of a long report. `inserted` is
3996    /// the only text here spar is answerable for, so it still passes through the
3997    /// style gate. The full body travels over stdin because a tracker can be far
3998    /// too long for one argument.
3999    pub fn edit_issue_body(
4000        &self,
4001        number: i64,
4002        expected: &str,
4003        body: &str,
4004        inserted: &str,
4005    ) -> Result<()> {
4006        let cleaned = self.record_failed_write(self.clean(inserted))?;
4007        if cleaned.trim() != inserted.trim() {
4008            return self.record_failed_write(Err(spar_err!(
4009                "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
4010            )));
4011        }
4012        let current = self.record_failed_write(self.issue_body(number))?;
4013        if current != expected {
4014            return self.record_failed_write(Err(spar_err!(
4015                "the body of #{number} changed since it was read, so it was left alone rather \
4016                 than written over."
4017            )));
4018        }
4019        let edited = self.gh_stdin(
4020            &["issue", "edit", &number.to_string(), "--body-file", "-"],
4021            body,
4022        );
4023        let result = match edited {
4024            Ok(_) => Ok(()),
4025            Err(edit_error) => {
4026                reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
4027            }
4028        };
4029        self.record_write(result)
4030    }
4031
4032    /// One issue's body, exactly as GitHub holds it.
4033    pub fn issue_body(&self, number: i64) -> Result<String> {
4034        #[derive(Deserialize)]
4035        struct Row {
4036            #[serde(default)]
4037            body: Option<String>,
4038        }
4039        let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
4040        let row: Row = serde_json::from_str(text.trim())
4041            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4042        Ok(row.body.unwrap_or_default())
4043    }
4044
4045    /// Every open issue with its title and body, in one call.
4046    ///
4047    /// For a screen that has to say something about each of twenty items before
4048    /// anything expensive happens. One call rather than one per issue.
4049    pub fn open_issue_rows(&self) -> Vec<Issue> {
4050        let text = self.gh_try(&[
4051            "issue",
4052            "list",
4053            "--state",
4054            "open",
4055            "--limit",
4056            &FETCH_CEILING.to_string(),
4057            "--json",
4058            "number,title,body,labels,state,stateReason,url",
4059        ]);
4060        serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
4061    }
4062
4063    /// Every open pull request with its size, in one call.
4064    pub fn open_pr_rows(&self) -> Vec<PrRow> {
4065        let text = self.gh_try(&[
4066            "pr",
4067            "list",
4068            "--state",
4069            "open",
4070            "--limit",
4071            &FETCH_CEILING.to_string(),
4072            "--json",
4073            "number,title,changedFiles,additions,deletions",
4074        ]);
4075        serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
4076    }
4077
4078    pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
4079        self.create_issue_apart_from(title, body, None)
4080    }
4081
4082    pub fn create_issue_apart_from(
4083        &self,
4084        title: &str,
4085        body: &str,
4086        apart_from: Option<i64>,
4087    ) -> Result<String> {
4088        let title = self.record_failed_write(self.clean_title(title))?;
4089        let body = self.record_failed_write(self.clean_issue_body(body))?;
4090        let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
4091        let result = match created {
4092            Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
4093            created => {
4094                let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
4095                reconcile_issue_creation(&title, created, found)
4096            }
4097        };
4098        self.record_write(result)
4099    }
4100}
4101
4102/// An issue that already covers what spar was about to file.
4103#[derive(Debug, Clone)]
4104pub struct ExistingIssue {
4105    pub number: i64,
4106    pub url: String,
4107    pub title: String,
4108    pub body: String,
4109    pub open: bool,
4110}
4111
4112impl Repo {
4113    pub(crate) fn try_exact_issue_apart_from(
4114        &self,
4115        title: &str,
4116        body: &str,
4117        apart_from: Option<i64>,
4118    ) -> Result<Option<ExistingIssue>> {
4119        #[derive(Deserialize)]
4120        #[serde(rename_all = "camelCase")]
4121        struct Row {
4122            number: i64,
4123            #[serde(default)]
4124            title: String,
4125            #[serde(default)]
4126            url: String,
4127            #[serde(default)]
4128            body: Option<String>,
4129            #[serde(default)]
4130            state: String,
4131        }
4132
4133        let text = self.gh(&[
4134            "issue",
4135            "list",
4136            "--state",
4137            "all",
4138            "--limit",
4139            "100",
4140            "--json",
4141            "number,title,url,body,state,stateReason",
4142        ])?;
4143        let rows = serde_json::from_str::<Vec<Row>>(text.trim())
4144            .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
4145        Ok(rows
4146            .into_iter()
4147            .filter(|row| Some(row.number) != apart_from)
4148            .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
4149            .map(|row| ExistingIssue {
4150                number: row.number,
4151                url: row.url,
4152                title: row.title,
4153                body: row.body.unwrap_or_default(),
4154                open: row.state.eq_ignore_ascii_case("open"),
4155            }))
4156    }
4157
4158    /// An issue that already describes this defect, however it was worded.
4159    ///
4160    /// Exact title matching let duplicates through: two agents, or two runs a
4161    /// week apart, never word one defect identically. A real run filed two
4162    /// duplicates that way, and each had to be closed by hand afterwards.
4163    /// Titles alone are too thin to match on, so this compares titles and
4164    /// bodies together.
4165    pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
4166        self.find_similar_issue_apart_from(title, body, None)
4167    }
4168
4169    /// The same search, with one issue that cannot be its own duplicate.
4170    ///
4171    /// A tracker's body quotes every item in its checklist, so searching for an
4172    /// item's words matches the tracker before it matches anything else. That
4173    /// would link an item to the issue it is written in.
4174    /// The search a tracker runs, which wants closed issues too: a checklist
4175    /// item matching a closed issue is a done item that wants a link.
4176    pub fn find_similar_issue_apart_from(
4177        &self,
4178        title: &str,
4179        body: &str,
4180        apart_from: Option<i64>,
4181    ) -> Option<ExistingIssue> {
4182        self.try_similar_issue(title, body, apart_from, true)
4183            .ok()
4184            .flatten()
4185    }
4186
4187    /// The search a filing runs, over open issues only.
4188    pub fn try_find_similar_issue_apart_from(
4189        &self,
4190        title: &str,
4191        body: &str,
4192        apart_from: Option<i64>,
4193    ) -> Result<Option<ExistingIssue>> {
4194        self.try_similar_issue(title, body, apart_from, false)
4195    }
4196
4197    /// The same search, preserving lookup failure for a caller about to write.
4198    fn try_similar_issue(
4199        &self,
4200        title: &str,
4201        body: &str,
4202        apart_from: Option<i64>,
4203        include_closed: bool,
4204    ) -> Result<Option<ExistingIssue>> {
4205        #[derive(Deserialize)]
4206        #[serde(rename_all = "camelCase")]
4207        struct Row {
4208            number: i64,
4209            #[serde(default)]
4210            title: String,
4211            #[serde(default)]
4212            url: String,
4213            #[serde(default)]
4214            body: String,
4215            #[serde(default)]
4216            state: String,
4217        }
4218        if title.trim().is_empty() {
4219            return Ok(None);
4220        }
4221        // Search on the title's own words: GitHub's index is the cheap way to
4222        // narrow the field before comparing properly.
4223        let query: String = title
4224            .chars()
4225            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4226            .take(120)
4227            .collect();
4228        // Open issues only when something is about to be filed. A fuzzy match
4229        // against a closed one is the case worth keeping rather than dropping:
4230        // a defect that was fixed, closed, and has come back is exactly the
4231        // finding a maintainer wants, and "#N covers it and is closed" wrote it
4232        // nowhere. An exact match is still checked against every state, since
4233        // that is a regression of one specific issue rather than a resemblance
4234        // to one, and a tracker still searches closed issues because a
4235        // checklist item matching one is a done item that wants a link.
4236        let state = if include_closed { "all" } else { "open" };
4237        let text = self.gh(&[
4238            "issue",
4239            "list",
4240            "--state",
4241            state,
4242            "--limit",
4243            "100",
4244            "--search",
4245            query.trim(),
4246            "--json",
4247            "number,title,url,body,state,stateReason",
4248        ])?;
4249        let rows: Vec<Row> = serde_json::from_str(text.trim())
4250            .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
4251
4252        Ok(rows
4253            .into_iter()
4254            .filter(|row| Some(row.number) != apart_from)
4255            .find(|row| {
4256                row.title.trim().eq_ignore_ascii_case(title.trim())
4257                    || textsim::covers(title, body, &row.title, &row.body)
4258            })
4259            .map(|row| ExistingIssue {
4260                number: row.number,
4261                url: row.url,
4262                title: row.title,
4263                open: row.state.eq_ignore_ascii_case("open"),
4264                body: row.body,
4265            }))
4266    }
4267
4268    /// Avoid filing a duplicate when a follow-up already exists.
4269    pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
4270        #[derive(Deserialize)]
4271        struct Row {
4272            title: String,
4273            url: String,
4274        }
4275        let needle = title.trim().to_lowercase();
4276        if needle.is_empty() {
4277            return None;
4278        }
4279        // Quotes and newlines would be read as search syntax rather than text.
4280        let query: String = title
4281            .chars()
4282            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4283            .take(120)
4284            .collect();
4285        let text = self.gh_try(&[
4286            "issue",
4287            "list",
4288            "--state",
4289            "all",
4290            "--limit",
4291            "100",
4292            "--search",
4293            query.trim(),
4294            "--json",
4295            "number,title,url",
4296        ]);
4297        serde_json::from_str::<Vec<Row>>(text.trim())
4298            .ok()?
4299            .into_iter()
4300            .find(|row| row.title.trim().to_lowercase() == needle)
4301            .map(|row| row.url)
4302    }
4303
4304    /// Squash merge, tolerating cleanup failures after a successful merge.
4305    ///
4306    /// Take a pull request out of draft, once the review has converged.
4307    ///
4308    /// Best effort for the remaining workflow. A failure does not discard the
4309    /// review or stop later independent work, but the final write summary
4310    /// reports it and the command returns non-zero.
4311    pub fn mark_ready(&self, number: i64) -> bool {
4312        match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
4313            Ok(_) => true,
4314            Err(e) => {
4315                logdim!(
4316                    "PR #{number} is approved but could not be taken out of draft: {}",
4317                    e.last_line()
4318                );
4319                false
4320            }
4321        }
4322    }
4323
4324    /// The worktree that has `branch` checked out, if one does.
4325    fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
4326        let listed = self
4327            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
4328            .ok()?;
4329        let wanted = format!("branch refs/heads/{branch}");
4330        let mut current: Option<PathBuf> = None;
4331        for line in listed.lines() {
4332            if let Some(path) = line.strip_prefix("worktree ") {
4333                current = Some(PathBuf::from(path));
4334            } else if line == wanted {
4335                return current;
4336            }
4337        }
4338        None
4339    }
4340
4341    /// Finish the local cleanup a merge could not.
4342    ///
4343    /// `gh pr merge --delete-branch` gives up on the local branch whenever any
4344    /// worktree has it checked out, which is the ordinary state rather than an
4345    /// odd one: `spar run` opens the pull request from
4346    /// `.spar-worktrees/issue-N` and leaves the checkout there, and the merge
4347    /// usually happens in a later `spar resume` working somewhere else. The
4348    /// next run's sweep tidied it, so nothing was ever lost, but the run that
4349    /// did the merge reported a failure about a branch nobody will touch again
4350    /// and left a checkout for the next run to explain.
4351    ///
4352    /// Held to the same rules the sweep uses, because they are the rules that
4353    /// decide whether anything would be lost: a branch spar recorded creating,
4354    /// a worktree of spar's own with no recoverable work in it, and a tip some
4355    /// surviving ref preserves. Anything else is left exactly where it is.
4356    fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
4357        let branch = self.pr_view(number).ok()?.head_ref_name;
4358        if branch.is_empty() || !self.known_branches().contains_key(&branch) {
4359            return None;
4360        }
4361        let mut released = None;
4362        if let Some(path) = self.worktree_holding(&branch) {
4363            if !path.starts_with(self.root.join(WORKTREE_DIR)) {
4364                return None;
4365            }
4366            if !matches!(
4367                self.has_recoverable_work(&path, Disposable::BuildOutput),
4368                Ok(false)
4369            ) {
4370                return None;
4371            }
4372            match self.remove_worktree_at(&path) {
4373                Ok(true) => released = Some(path),
4374                _ => return None,
4375            }
4376        }
4377        match self.delete_branch_if_safe(&branch) {
4378            Ok(true) => {
4379                self.forget_branch(&branch);
4380                Some(match released {
4381                    Some(path) => format!(
4382                        " The worktree at {} was released and branch {branch} deleted.",
4383                        path.display()
4384                    ),
4385                    None => format!(" Branch {branch} was deleted."),
4386                })
4387            }
4388            _ => released.map(|path| {
4389                format!(
4390                    " The worktree at {} was released, but branch {branch} was kept.",
4391                    path.display()
4392                )
4393            }),
4394        }
4395    }
4396
4397    /// `gh pr merge --delete-branch` exits non-zero when it cannot delete the
4398    /// local branch, which happens *after* the merge has already landed.
4399    /// Treating that as a failure reports work as lost when it is not.
4400    pub fn merge_pr(&self, number: i64) -> Result<()> {
4401        let n = number.to_string();
4402        let merged = match self.gh(&merge_pr_args(&n, None, true)) {
4403            Ok(_) => Ok(()),
4404            Err(e) => {
4405                if self.pr_state(number) == "MERGED" {
4406                    let finished = self
4407                        .finish_merged_branch_cleanup(number)
4408                        .unwrap_or_default();
4409                    logdim!(
4410                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
4411                        e.last_line()
4412                    );
4413                    Ok(())
4414                } else {
4415                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4416                }
4417            }
4418        };
4419        self.record_write(merged)
4420    }
4421
4422    /// Squash merge only if the pull request still exposes the reviewed head.
4423    pub fn merge_pr_at_head(
4424        &self,
4425        number: i64,
4426        expected_head: &str,
4427        delete_branch: bool,
4428    ) -> Result<()> {
4429        let n = number.to_string();
4430        let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
4431            Ok(_) => Ok(()),
4432            Err(e) => {
4433                if self.pr_state(number) == "MERGED" {
4434                    let finished = self
4435                        .finish_merged_branch_cleanup(number)
4436                        .unwrap_or_default();
4437                    logdim!(
4438                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
4439                        e.last_line()
4440                    );
4441                    Ok(())
4442                } else {
4443                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4444                }
4445            }
4446        };
4447        self.record_write(merged)
4448    }
4449
4450    // -- follow-ups -------------------------------------------------------
4451
4452    /// The queue of follow-ups recorded locally rather than filed, which
4453    /// `spar followup` works.
4454    pub fn followups_path(&self) -> PathBuf {
4455        self.root.join(STATE_DIR).join("followups.md")
4456    }
4457
4458    /// Where `spar brainstorm` writes a session unless told otherwise.
4459    ///
4460    /// Under the state directory, so it is kept out of the repository's own
4461    /// `git status`; `--out` puts one somewhere a person would commit.
4462    pub fn brainstorms_dir(&self) -> PathBuf {
4463        self.root.join(STATE_DIR).join("brainstorms")
4464    }
4465
4466    /// What `spar followup` already dealt with, kept beside the queue.
4467    ///
4468    /// Two jobs. It is what stops `append_local_followup` re-recording a
4469    /// follow-up whose entry has since left the queue, which would otherwise
4470    /// turn the file into a ring buffer of things already filed. And it keeps
4471    /// the text of an entry a screening pass ruled stale, so a wrong verdict
4472    /// costs a re-read rather than the only copy of a real defect.
4473    pub fn worked_followups_path(&self) -> PathBuf {
4474        self.root.join(STATE_DIR).join("followups.done.md")
4475    }
4476
4477    /// What `spar checkin` has already answered on one pull request or issue.
4478    /// Where what a run spent is kept.
4479    pub fn spend_path(&self) -> PathBuf {
4480        self.root.join(STATE_DIR).join("state").join("spend.json")
4481    }
4482
4483    /// Append this run's calls to the record.
4484    ///
4485    /// Appended rather than replaced, so a resumed pull request adds to the
4486    /// count of what it has cost so far rather than starting it again, and so
4487    /// anybody charting this has a file to chart.
4488    pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
4489        if calls.is_empty() {
4490            return Ok(());
4491        }
4492        let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
4493            .ok()
4494            .and_then(|text| serde_json::from_str(&text).ok())
4495            .unwrap_or_default();
4496        all.extend(calls.iter().cloned());
4497        if let Some(parent) = self.spend_path().parent() {
4498            let _ = std::fs::create_dir_all(parent);
4499        }
4500        write_json_atomic(&self.spend_path(), &all)
4501    }
4502
4503    /// Who wrote the first draft of each issue, by number.
4504    ///
4505    /// Custody alternates within a pull request and the record of it lives in
4506    /// that pull request's state. This is the other question, which nothing
4507    /// answered: over a queue, which model authored what.
4508    pub fn implementors_path(&self) -> PathBuf {
4509        self.root
4510            .join(STATE_DIR)
4511            .join("state")
4512            .join("implementors.json")
4513    }
4514
4515    pub fn read_implementors(&self) -> BTreeMap<i64, String> {
4516        std::fs::read_to_string(self.implementors_path())
4517            .ok()
4518            .and_then(|text| serde_json::from_str(&text).ok())
4519            .unwrap_or_default()
4520    }
4521
4522    /// Best effort: this is a record for a person, not something the loop reads
4523    /// back, so a failure to write it is not a reason to stop.
4524    pub fn remember_implementor(&self, issue: i64, agent: &str) {
4525        let mut known = self.read_implementors();
4526        known.insert(issue, agent.to_string());
4527        if let Some(parent) = self.implementors_path().parent() {
4528            let _ = std::fs::create_dir_all(parent);
4529        }
4530        if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
4531            logdim!("could not record who implemented #{issue}: {e}");
4532        }
4533    }
4534
4535    /// Where the issues triage disagreed about are remembered.
4536    ///
4537    /// One file rather than one per issue: it is read once per run, before any
4538    /// triage call, to decide what not to ask about again.
4539    pub fn contested_path(&self) -> PathBuf {
4540        self.root
4541            .join(STATE_DIR)
4542            .join("state")
4543            .join("contested.json")
4544    }
4545
4546    /// The issues a person has been asked to decide, by number.
4547    pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
4548        std::fs::read_to_string(self.contested_path())
4549            .ok()
4550            .and_then(|text| serde_json::from_str(&text).ok())
4551            .unwrap_or_default()
4552    }
4553
4554    /// Remember an issue triage could not agree on.
4555    ///
4556    /// Best effort: a run that cannot write this asks again next time, which is
4557    /// what it did before this existed.
4558    pub fn remember_contested(&self, items: &[ContestedItem]) {
4559        if items.is_empty() {
4560            return;
4561        }
4562        let mut known = self.read_contested();
4563        for item in items {
4564            known.insert(
4565                item.issue,
4566                Contested {
4567                    title: item.title.clone(),
4568                    positions: item.positions.clone(),
4569                    reasons: item.reasons.clone(),
4570                },
4571            );
4572        }
4573        if let Some(parent) = self.contested_path().parent() {
4574            let _ = std::fs::create_dir_all(parent);
4575        }
4576        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4577            logdim!("could not record the contested issues: {e}");
4578        }
4579    }
4580
4581    /// Ask about them again, because a person has acted on them.
4582    pub fn forget_contested(&self, numbers: &[i64]) {
4583        let mut known = self.read_contested();
4584        let before = known.len();
4585        known.retain(|number, _| !numbers.contains(number));
4586        if known.len() == before {
4587            return;
4588        }
4589        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4590            logdim!("could not update the contested issues: {e}");
4591        }
4592    }
4593
4594    pub fn checkin_state_path(&self, number: i64) -> PathBuf {
4595        self.root
4596            .join(STATE_DIR)
4597            .join("state")
4598            .join(format!("checkin-{number}.json"))
4599    }
4600
4601    /// Append a follow-up to a local note instead of the tracker.
4602    ///
4603    /// Deduplicated on the title, matching the issue path. The body arrives
4604    /// with its provenance already stamped by the caller, so nothing is added
4605    /// here.
4606    ///
4607    /// A write that did not happen is reported as such rather than as a
4608    /// duplicate: the caller settles the point on the strength of this answer,
4609    /// and settling it on a failed write is how a real defect is lost.
4610    ///
4611    /// Both files are checked, because `spar followup` removes an entry from
4612    /// the queue once it has filed it. Checking only the queue would let the
4613    /// next run that rediscovers the same defect append it again, on top of the
4614    /// issue that now exists for it.
4615    pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
4616        let path = self.followups_path();
4617        let heading = format!("## {}", title.trim());
4618        for seen in [&path, &self.worked_followups_path()] {
4619            if let Ok(existing) = std::fs::read_to_string(seen) {
4620                if has_heading(&existing, title) {
4621                    logdim!("follow-up already noted: {title}");
4622                    return Followup::Covered(format!("note: {}", title.trim()));
4623                }
4624            }
4625        }
4626        if let Some(parent) = path.parent() {
4627            let _ = std::fs::create_dir_all(parent);
4628        }
4629        use std::io::Write;
4630        // The caller already stamped the provenance into the body. Adding
4631        // "From #N." here as well printed it twice, in two different wordings.
4632        //
4633        // The marker above the heading is what makes the entry boundary
4634        // unambiguous to the parser, since the body's own sections are written
4635        // at the same heading level as the title.
4636        let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
4637        match std::fs::OpenOptions::new()
4638            .create(true)
4639            .append(true)
4640            .open(&path)
4641        {
4642            Ok(mut file) => match file.write_all(entry.as_bytes()) {
4643                Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
4644                Err(e) => {
4645                    logdim!("could not write {}: {e}", path.display());
4646                    Followup::Failed
4647                }
4648            },
4649            Err(e) => {
4650                logdim!("could not write {}: {e}", path.display());
4651                Followup::Failed
4652            }
4653        }
4654    }
4655
4656    /// Record what `spar followup` did with an entry, and why.
4657    ///
4658    /// Best effort: an archive that could not be written is not a reason to
4659    /// stop, since the entry has already been filed or ruled on.
4660    pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
4661        let path = self.worked_followups_path();
4662        if let Some(parent) = path.parent() {
4663            let _ = std::fs::create_dir_all(parent);
4664        }
4665        use std::io::Write;
4666        let entry = format!(
4667            "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
4668            title.trim(),
4669            body.trim()
4670        );
4671        if let Ok(mut file) = std::fs::OpenOptions::new()
4672            .create(true)
4673            .append(true)
4674            .open(&path)
4675        {
4676            let _ = file.write_all(entry.as_bytes());
4677        }
4678    }
4679
4680    // -- resumable state --------------------------------------------------
4681    //
4682    // Custody cannot be read from GitHub authorship: every agent commits and
4683    // comments as the same git identity, so `author` is always the human who
4684    // ran spar. State is kept on disk by default and can additionally travel in
4685    // a PR comment, which is what lets a run be resumed from another machine.
4686
4687    /// Where a comment spar produced but did not post is kept.
4688    pub fn pending_comment_path(&self, number: i64) -> PathBuf {
4689        self.root
4690            .join(STATE_DIR)
4691            .join("reviews")
4692            .join(format!("pr-{number}.md"))
4693    }
4694
4695    /// Keep a comment spar decided not to post.
4696    ///
4697    /// A dry run that prints and forgets means agreeing with what you read
4698    /// costs a second full review. Saving it makes the whole point of reading
4699    /// it first: look, edit if you like, then post what you already paid for.
4700    pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
4701        let path = self.pending_comment_path(number);
4702        if let Some(parent) = path.parent() {
4703            std::fs::create_dir_all(parent)
4704                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
4705        }
4706        std::fs::write(&path, text)
4707            .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4708        Ok(path)
4709    }
4710
4711    pub fn read_pending_comment(&self, number: i64) -> Option<String> {
4712        std::fs::read_to_string(self.pending_comment_path(number)).ok()
4713    }
4714
4715    pub fn state_path(&self, number: i64) -> PathBuf {
4716        self.root
4717            .join(STATE_DIR)
4718            .join("state")
4719            .join(format!("pr-{number}.json"))
4720    }
4721
4722    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
4723        let path = self.state_path(number);
4724        let text = std::fs::read_to_string(&path).ok()?;
4725        match serde_json::from_str(&text) {
4726            Ok(state) => Some(state),
4727            Err(_) => {
4728                logdim!("could not read {}, starting fresh", path.display());
4729                None
4730            }
4731        }
4732    }
4733
4734    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
4735        if let Some(local) = self.read_local_state(pr.number) {
4736            return Some(local);
4737        }
4738        if self.state_store.writes_pr() {
4739            return self.read_pr_state(pr.number);
4740        }
4741        None
4742    }
4743
4744    pub(crate) fn read_state_for_head(
4745        &self,
4746        pr: &PrView,
4747        actual_head: &str,
4748    ) -> Option<PersistedState> {
4749        let local = self
4750            .state_store
4751            .writes_local()
4752            .then(|| self.read_local_state(pr.number))
4753            .flatten();
4754        let remote = self
4755            .state_store
4756            .writes_pr()
4757            .then(|| self.read_pr_state(pr.number))
4758            .flatten();
4759        let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
4760        if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
4761            self.remember_checkpoint(pr.number, checkpoint);
4762        }
4763        choose_state_for_head(candidates, actual_head)
4764    }
4765
4766    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
4767        self.try_read_pr_state(number).ok().flatten()
4768    }
4769
4770    fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
4771        for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
4772            if let Some(state) = parse_state_comment(&body) {
4773                return Ok(Some(state));
4774            }
4775        }
4776        Ok(None)
4777    }
4778
4779    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4780        let remote_state = if self.state_store.writes_pr() {
4781            self.try_read_pr_state(number)
4782        } else {
4783            Ok(None)
4784        };
4785        self.write_state_after_remote_read(number, state, remote_state)
4786    }
4787
4788    fn write_state_after_remote_read(
4789        &self,
4790        number: i64,
4791        state: &PersistedState,
4792        remote_state: Result<Option<PersistedState>>,
4793    ) -> Result<()> {
4794        let remote_checkpoint = if self.state_store.writes_pr() {
4795            self.record_failed_write(remote_state)?
4796                .map(|saved| saved.checkpoint)
4797                .unwrap_or_default()
4798        } else {
4799            0
4800        };
4801        let local_checkpoint = self
4802            .state_store
4803            .writes_local()
4804            .then(|| self.read_local_state(number))
4805            .flatten()
4806            .map(|saved| saved.checkpoint)
4807            .unwrap_or_default();
4808        let mut stamped = state.clone();
4809        stamped.checkpoint = state
4810            .checkpoint
4811            .max(local_checkpoint)
4812            .max(remote_checkpoint)
4813            .max(self.remembered_checkpoint(number))
4814            .saturating_add(1);
4815        self.remember_checkpoint(number, stamped.checkpoint);
4816        if self.state_store.writes_local() {
4817            write_json_atomic(&self.state_path(number), &stamped)?;
4818        }
4819        if self.state_store.writes_pr() {
4820            self.write_pr_state(number, &stamped)?;
4821        }
4822        Ok(())
4823    }
4824
4825    fn remembered_checkpoint(&self, number: i64) -> u64 {
4826        self.checkpoints
4827            .lock()
4828            .unwrap_or_else(std::sync::PoisonError::into_inner)
4829            .get(&number)
4830            .copied()
4831            .unwrap_or_default()
4832    }
4833
4834    fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
4835        let mut checkpoints = self
4836            .checkpoints
4837            .lock()
4838            .unwrap_or_else(std::sync::PoisonError::into_inner);
4839        let saved = checkpoints.entry(number).or_default();
4840        *saved = (*saved).max(checkpoint);
4841    }
4842
4843    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4844        // Not run through clean(): this is structured data, and scrubbing would
4845        // corrupt refutation text stored in the ledger. It sits inside an
4846        // unclosed HTML comment so GitHub renders it as nothing.
4847        let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
4848        let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
4849        let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
4850        if let Some(id) = comment_id {
4851            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4852            let field = format!("body={body}");
4853            let written = self
4854                .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
4855                .map(|_| ());
4856            return self.record_write(written);
4857        }
4858        let written = self
4859            .gh(&["pr", "comment", &number.to_string(), "--body", &body])
4860            .map(|_| ());
4861        self.record_write(written)
4862    }
4863
4864    /// Top level comments. Works for issues and pull requests alike, because
4865    /// GitHub serves both from the issues endpoint.
4866    ///
4867    /// Nothing when they cannot be read, which suits a reader that is going to
4868    /// go on regardless. A caller deciding whether it has already written here
4869    /// wants `try_issue_comments`, since for that one no comments and no answer
4870    /// are opposite answers.
4871    pub fn issue_comments(&self, number: i64) -> Vec<Value> {
4872        self.try_issue_comments(number).unwrap_or_default()
4873    }
4874
4875    pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
4876        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
4877        try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
4878    }
4879
4880    /// spar's own state comments on a pull request, newest last.
4881    ///
4882    /// Three tests, because the answer decides what gets patched over and what
4883    /// gets deleted. A comment matching the marker loosely, which is what
4884    /// somebody asking "why is there a `<!-- spar:state` block on this PR?"
4885    /// writes, is named in the log and left exactly where it is.
4886    fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
4887        let viewer = self.viewer_login()?.to_string();
4888        let mut mine = Vec::new();
4889        let mut theirs = Vec::new();
4890        for comment in self.try_issue_comments(number)? {
4891            let Some(body) = comment.get("body").and_then(Value::as_str) else {
4892                continue;
4893            };
4894            if !body.contains("spar:state") {
4895                continue;
4896            }
4897            let Some(id) = comment.get("id").and_then(Value::as_i64) else {
4898                continue;
4899            };
4900            let author = comment
4901                .get("user")
4902                .and_then(|u| u.get("login"))
4903                .and_then(Value::as_str)
4904                .unwrap_or_default();
4905            if is_state_comment(body, author, &viewer) {
4906                mine.push((id, body.to_string()));
4907            } else {
4908                theirs.push(format!("@{author}"));
4909            }
4910        }
4911        if !theirs.is_empty() {
4912            logdim!(
4913                "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
4914                 own state block is patched or deleted.",
4915                theirs.len(),
4916                theirs.join(", ")
4917            );
4918        }
4919        Ok(mine)
4920    }
4921
4922    fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
4923        Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
4924    }
4925
4926    /// Drop state once the PR is finished and there is nothing to resume.
4927    pub fn clear_state(&self, number: i64) {
4928        let path = self.state_path(number);
4929        let _ = std::fs::remove_file(&path);
4930        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
4931    }
4932
4933    // -- housekeeping -----------------------------------------------------
4934
4935    /// Remove state files whose PR is merged or closed.
4936    pub fn prune_state(&self) -> Vec<String> {
4937        let base = self.root.join(STATE_DIR).join("state");
4938        let Ok(entries) = std::fs::read_dir(&base) else {
4939            return Vec::new();
4940        };
4941        let mut names: Vec<String> = entries
4942            .flatten()
4943            .filter_map(|e| e.file_name().to_str().map(str::to_string))
4944            .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
4945            .collect();
4946        names.sort();
4947
4948        let mut removed = Vec::new();
4949        for name in names {
4950            let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
4951                continue;
4952            };
4953            if is_finished(&self.pr_state(number)) {
4954                let _ = std::fs::remove_file(base.join(&name));
4955                removed.push(format!("state {name}"));
4956            }
4957        }
4958        removed
4959    }
4960
4961    /// Delete state comments from PRs that are finished.
4962    ///
4963    /// Open PRs are left alone: their state may still be live.
4964    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
4965        #[derive(Deserialize)]
4966        struct Row {
4967            number: i64,
4968        }
4969        let numbers = match numbers {
4970            Some(numbers) => numbers,
4971            None => {
4972                let listed: Result<Vec<i64>> = (|| {
4973                    let text = self.gh(&[
4974                        "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
4975                    ])?;
4976                    let rows = serde_json::from_str::<Vec<Row>>(text.trim())
4977                        .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
4978                    Ok(rows.into_iter().map(|row| row.number).collect())
4979                })();
4980                match self.record_failed_write(listed) {
4981                    Ok(numbers) => numbers,
4982                    Err(e) => {
4983                        logdim!("could not inspect pull requests for state cleanup: {e}");
4984                        return Vec::new();
4985                    }
4986                }
4987            }
4988        };
4989
4990        let mut removed = Vec::new();
4991        for number in numbers {
4992            let state = match self.record_failed_write(self.try_pr_state(number)) {
4993                Ok(state) => state,
4994                Err(e) => {
4995                    logdim!("could not inspect PR #{number} for state cleanup: {e}");
4996                    continue;
4997                }
4998            };
4999            if !is_finished(&state) {
5000                continue;
5001            }
5002            let comments = match self.record_failed_write(self.try_state_comments(number)) {
5003                Ok(comments) => comments,
5004                Err(e) => {
5005                    logdim!("could not inspect state comments on PR #{number}: {e}");
5006                    continue;
5007                }
5008            };
5009            for (id, _) in comments {
5010                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
5011                let deleted = self
5012                    .gh(&["api", "-X", "DELETE", &path, "--silent"])
5013                    .map(|_| ());
5014                match self.record_write(deleted) {
5015                    Ok(()) => removed.push(format!("state comment on PR #{number}")),
5016                    Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
5017                }
5018            }
5019        }
5020        removed
5021    }
5022
5023    /// Drop worktrees whose PR is finished, then the branches they left behind.
5024    ///
5025    /// With auto_merge off, which is the default, a run ends at "approved", so
5026    /// nothing would ever clean these up on its own and they accumulate one per
5027    /// run. A stranded worktree also holds its branch checked out, which makes
5028    /// a later `gh pr merge --delete-branch` fail to clean up.
5029    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
5030        let base = self.root.join(WORKTREE_DIR);
5031        let mut removed = Vec::new();
5032        let known = self.known_branches();
5033
5034        if let Ok(entries) = std::fs::read_dir(&base) {
5035            let mut names: Vec<String> = entries
5036                .flatten()
5037                .filter(|e| e.path().is_dir())
5038                .filter_map(|e| e.file_name().to_str().map(str::to_string))
5039                .collect();
5040            names.sort();
5041
5042            for name in names {
5043                // A review worktree is detached and owns no branch, so it is
5044                // tied to the pull request only by its directory name.
5045                if let Some(rest) = name.strip_prefix("review-") {
5046                    let number: i64 = rest.parse().unwrap_or(-1);
5047                    if !(force_all || is_finished(&self.pr_state(number))) {
5048                        continue;
5049                    }
5050                    let path = base.join(&name);
5051                    if force_all {
5052                        let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
5053                            if !belongs {
5054                                return Ok(false);
5055                            }
5056                            let local_ref = review_ref(number);
5057                            if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
5058                                return Ok(false);
5059                            }
5060                            let head = self.head_oid_checked(&path)?;
5061                            let recorded = self
5062                                .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
5063                                .trim()
5064                                .to_string();
5065                            Ok(head == recorded)
5066                        });
5067                        match owned {
5068                            Ok(true) => {}
5069                            Ok(false) => {
5070                                logdim!(
5071                                    "kept {} because no matching SPAR review reference proves \
5072                                     ownership",
5073                                    path.display()
5074                                );
5075                                continue;
5076                            }
5077                            Err(e) => {
5078                                logdim!(
5079                                    "kept {} because review ownership could not be verified: {}",
5080                                    path.display(),
5081                                    e.last_line()
5082                                );
5083                                continue;
5084                            }
5085                        }
5086                    } else {
5087                        if let Err(e) = self.refuse_review_worktree_changes(number) {
5088                            logdim!(
5089                                "kept {} because its review state could not be verified as \
5090                                 disposable: {}",
5091                                path.display(),
5092                                e.last_line()
5093                            );
5094                            continue;
5095                        }
5096                    }
5097                    if force_all {
5098                        if self.remove_worktree_at_force(&path) {
5099                            self.git_try(&["update-ref", "-d", &review_ref(number)]);
5100                        }
5101                    } else {
5102                        self.release_review_worktree(number);
5103                    }
5104                    if !path.exists() {
5105                        removed.push(name);
5106                    }
5107                    continue;
5108                }
5109                let branch = format!("{}{name}", self.branch_prefix);
5110                if !(force_all || self.worktree_is_done(&branch)) {
5111                    continue;
5112                }
5113                if !known.contains_key(&branch) {
5114                    logdim!("kept {branch} because it has no branch record");
5115                    continue;
5116                }
5117                let path = base.join(&name);
5118                if !force_all {
5119                    match self.has_recoverable_work(&path, Disposable::BuildOutput) {
5120                        Ok(true) => {
5121                            logdim!(
5122                                "kept {} because it contains uncommitted changes or ignored files",
5123                                path.display()
5124                            );
5125                            continue;
5126                        }
5127                        Err(e) => {
5128                            logdim!(
5129                                "kept {} because its Git state could not be checked: {}",
5130                                path.display(),
5131                                e.last_line()
5132                            );
5133                            continue;
5134                        }
5135                        Ok(false) => {}
5136                    }
5137                    match self.branch_deletion_is_safe(&branch) {
5138                        Ok(true) => {}
5139                        Ok(false) => {
5140                            logdim!(
5141                                "kept {branch} because no surviving ref preserves its tip or \
5142                                 reflog-only commits"
5143                            );
5144                            continue;
5145                        }
5146                        Err(e) => {
5147                            logdim!(
5148                                "kept {branch} because preservation could not be verified: {}",
5149                                e.last_line()
5150                            );
5151                            continue;
5152                        }
5153                    }
5154                }
5155                let removed_worktree = if force_all {
5156                    self.remove_worktree_at_force(&path)
5157                } else {
5158                    match self.remove_worktree_at(&path) {
5159                        Ok(removed) => removed,
5160                        Err(error) => {
5161                            logdim!(
5162                                "kept {branch} and {} because removal did not reach a confirmed \
5163                                 quiet point: {}",
5164                                path.display(),
5165                                error.last_line()
5166                            );
5167                            false
5168                        }
5169                    }
5170                };
5171                if !removed_worktree {
5172                    continue;
5173                }
5174                if force_all {
5175                    self.git_try(&["branch", "-D", &branch]);
5176                    self.forget_branch(&branch);
5177                } else {
5178                    match self.delete_branch_if_safe(&branch) {
5179                        Ok(true) => self.forget_branch(&branch),
5180                        Ok(false) => logdim!(
5181                            "kept {branch} because its tip or reflog changed before deletion"
5182                        ),
5183                        Err(error) => logdim!(
5184                            "kept {branch} because deletion safety could not be rechecked: {}",
5185                            error.last_line()
5186                        ),
5187                    }
5188                }
5189                removed.push(name);
5190            }
5191        }
5192        removed.extend(self.prune_branches(force_all));
5193        removed
5194    }
5195
5196    /// Delete leftover branches spar created whose worktree is already gone.
5197    ///
5198    /// Deletion is driven by the ledger of branches spar actually created, not
5199    /// by a name pattern. Names default to `issue-N`, which is exactly what a
5200    /// person would call a branch themselves, so a name alone can never
5201    /// establish ownership. This is the data loss guard.
5202    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
5203        let known = self.known_branches();
5204        let branches: Vec<String> = known.keys().cloned().collect();
5205        if branches.is_empty() {
5206            return Vec::new();
5207        }
5208
5209        let checked_out: Vec<String> = self
5210            .git_try(&["worktree", "list", "--porcelain"])
5211            .lines()
5212            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
5213            .collect();
5214
5215        // %(refname:short) is ambiguous when a tag shares the branch name (it
5216        // yields "heads/..."), so take the full ref and strip it here.
5217        let existing: Vec<String> = self
5218            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
5219            .lines()
5220            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
5221            .collect();
5222
5223        let mut removed = Vec::new();
5224        for branch in branches {
5225            if !existing.contains(&branch) {
5226                self.forget_branch(&branch); // already gone, drop the record
5227                continue;
5228            }
5229            if checked_out.contains(&branch) {
5230                continue;
5231            }
5232            if !(force_all || self.worktree_is_done(&branch)) {
5233                continue;
5234            }
5235            if !force_all {
5236                let Some(_record) = known.get(&branch) else {
5237                    continue;
5238                };
5239                match self.branch_deletion_is_safe(&branch) {
5240                    Ok(true) => {}
5241                    Ok(false) => {
5242                        logdim!(
5243                            "kept {branch} because no surviving ref preserves its tip or \
5244                             reflog-only commits"
5245                        );
5246                        continue;
5247                    }
5248                    Err(e) => {
5249                        logdim!(
5250                            "kept {branch} because preservation could not be verified: {}",
5251                            e.last_line()
5252                        );
5253                        continue;
5254                    }
5255                }
5256            }
5257            let deleted = if force_all {
5258                self.git(&["branch", "-D", &branch]).map(|_| true)
5259            } else {
5260                self.delete_branch_if_safe(&branch)
5261            };
5262            match deleted {
5263                Ok(true) => {
5264                    self.forget_branch(&branch);
5265                    removed.push(format!("branch {branch}"));
5266                }
5267                Ok(false) => {
5268                    logdim!("kept {branch} because its tip or reflog changed before deletion");
5269                }
5270                Err(e) => {
5271                    // A branch that silently survives pruning looks like a spar
5272                    // bug, so the name and git's own reason have to be said.
5273                    logdim!("could not delete {branch}: {}", e.last_line());
5274                }
5275            }
5276        }
5277        removed
5278    }
5279
5280    /// True when the PR behind this branch is merged or closed.
5281    fn worktree_is_done(&self, branch: &str) -> bool {
5282        #[derive(Deserialize)]
5283        struct Row {
5284            state: String,
5285        }
5286        let entry = branch
5287            .strip_prefix(self.branch_prefix.as_str())
5288            .unwrap_or(branch);
5289        if let Some(rest) = entry.strip_prefix("pr-") {
5290            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
5291        }
5292        // A split part is the same shape as an issue branch: one branch, whose
5293        // pull requests say whether it is finished. Without it here, a part
5294        // branch is one nothing but `clean --all` would ever remove.
5295        if entry.starts_with("issue-") || entry.starts_with("split-") {
5296            let text = self.gh_try(&[
5297                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
5298            ]);
5299            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
5300            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
5301        }
5302        false
5303    }
5304}
5305
5306// ---------------------------------------------------------------------------
5307// Free helpers
5308// ---------------------------------------------------------------------------
5309
5310/// Take a read-only probe again before treating its failure as evidence.
5311///
5312/// Every probe is a Git invocation under a thirty second cap plus a walk over a
5313/// tree an agent may still be building in. A probe that fails proves nothing
5314/// about the worktree, and there is a real difference between "the tree moved"
5315/// and "the question could not be asked just then", which the caller turns into
5316/// the same run-ending error.
5317pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
5318    match probe() {
5319        Ok(value) => Ok(value),
5320        Err(first) => {
5321            logdim!("retrying a worktree probe after: {}", first.last_line());
5322            probe()
5323        }
5324    }
5325}
5326
5327/// Read attribute files without asking Git to inspect working-tree content.
5328///
5329/// A newly written attribute can select a clean or smudge filter. It must be
5330/// detected before a post-call status, diff, or add command has a chance to run
5331/// that filter in the parent process.
5332pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
5333    let root = std::fs::canonicalize(cwd)
5334        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5335    let mut files = BTreeMap::new();
5336    let mut visited = BTreeSet::new();
5337    collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
5338    Ok(AttributeState { files })
5339}
5340
5341fn collect_attribute_files(
5342    root: &Path,
5343    repository: &Path,
5344    prefix: &Path,
5345    visited: &mut BTreeSet<PathBuf>,
5346    files: &mut BTreeMap<PathBuf, [u8; 32]>,
5347) -> Result<()> {
5348    let canonical = std::fs::canonicalize(repository)
5349        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5350    if !visited.insert(canonical) {
5351        bail!("submodule recursion revisited {}", repository.display());
5352    }
5353    let entries = index_entries(repository)?;
5354    let mut paths: BTreeSet<PathBuf> = entries
5355        .iter()
5356        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5357        .map(|entry| entry.path.clone())
5358        .collect();
5359    // Ignored on purpose. A `.gitattributes` under build output can only select
5360    // filters for paths that are themselves ignored, and no ignored path is
5361    // ever staged or compared as tracked content, so it cannot reach a managed
5362    // commit. Capturing it meant `cargo package` or `npm ci` writing one into
5363    // its own output ended a read-only call outright.
5364    paths.extend(untracked_attribute_paths(repository)?);
5365    for path in paths {
5366        let from_root = prefix.join(&path);
5367        let state = attribute_file_fingerprint(&root.join(&from_root))?;
5368        files.insert(from_root, state);
5369    }
5370    for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
5371        let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
5372            continue;
5373        };
5374        collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
5375    }
5376    Ok(())
5377}
5378
5379/// Leave a visible, untracked reason ordinary cleanup can detect on a later
5380/// run even when Git status normalizes the original working-file change away.
5381pub(crate) fn uncertain_worktree_change(
5382    cwd: &Path,
5383    message: impl Into<String>,
5384) -> crate::error::SparError {
5385    let message = message.into();
5386    let marker = write_recovery_marker(cwd, &message);
5387    let note = match marker {
5388        Ok(path) => format!(" Recovery marker: {}.", path.display()),
5389        Err(e) => format!(
5390            " A recovery marker could not be written: {}.",
5391            e.last_line()
5392        ),
5393    };
5394    crate::error::SparError::uncertain_write(format!("{message}{note}"))
5395}
5396
5397fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
5398    use std::sync::atomic::{AtomicU32, Ordering};
5399    static NEXT: AtomicU32 = AtomicU32::new(0);
5400    for _ in 0..1000 {
5401        let serial = NEXT.fetch_add(1, Ordering::Relaxed);
5402        let path = cwd.join(format!(
5403            "{RECOVERY_MARKER_PREFIX}{}-{serial}",
5404            std::process::id()
5405        ));
5406        let mut options = OpenOptions::new();
5407        options.write(true).create_new(true);
5408        #[cfg(unix)]
5409        {
5410            use std::os::unix::fs::OpenOptionsExt;
5411            options.mode(0o600);
5412        }
5413        match options.open(&path) {
5414            Ok(mut file) => {
5415                file.write_all(detail.as_bytes())
5416                    .and_then(|_| file.write_all(b"\n"))
5417                    .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5418                return Ok(path);
5419            }
5420            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
5421            Err(e) => {
5422                return Err(spar_err!(
5423                    "could not create a recovery marker in {}: {e}",
5424                    cwd.display()
5425                ))
5426            }
5427        }
5428    }
5429    bail!(
5430        "could not choose a free recovery marker name in {}",
5431        cwd.display()
5432    )
5433}
5434
5435/// Build a Git command that cannot launch automatic repository maintenance.
5436///
5437/// A fetch may otherwise prune missing linked worktree registrations. SPAR
5438/// must only remove registrations it has proven it owns.
5439fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
5440    let mut argv = vec![
5441        "git".to_string(),
5442        "-c".to_string(),
5443        "maintenance.auto=false".to_string(),
5444        "-c".to_string(),
5445        "gc.auto=0".to_string(),
5446    ];
5447    argv.extend(args.iter().map(|arg| (*arg).to_string()));
5448    argv
5449}
5450
5451fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
5452    let mut argv = git_without_maintenance_argv(&[]);
5453    argv.extend([
5454        "-c".to_string(),
5455        "core.fsmonitor=".to_string(),
5456        "-c".to_string(),
5457        "commit.gpgsign=false".to_string(),
5458        "-c".to_string(),
5459        "core.hooksPath=/dev/null".to_string(),
5460    ]);
5461    argv.extend(args.iter().map(|arg| (*arg).to_string()));
5462    argv
5463}
5464
5465/// Snapshot every untracked file, including ignored files, without changing
5466/// path bytes.
5467///
5468/// Without an exclude option, Git lists both ordinary and ignored untracked
5469/// entries. Metadata fingerprints make overwriting an existing path observable
5470/// without hashing a potentially multi-gigabyte build tree on every call.
5471pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
5472    let root = std::fs::canonicalize(cwd)
5473        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5474    let mut files = BTreeMap::new();
5475    let mut ignored = BTreeSet::new();
5476    let mut visited = BTreeSet::new();
5477    collect_untracked_files(
5478        &root,
5479        &root,
5480        Path::new(""),
5481        &mut visited,
5482        &mut files,
5483        &mut ignored,
5484    )?;
5485    Ok(IgnoredState { files, ignored })
5486}
5487
5488fn collect_untracked_files(
5489    root: &Path,
5490    repository: &Path,
5491    prefix: &Path,
5492    visited: &mut BTreeSet<PathBuf>,
5493    files: &mut BTreeMap<PathBuf, UntrackedFile>,
5494    ignored: &mut BTreeSet<PathBuf>,
5495) -> Result<()> {
5496    let canonical = std::fs::canonicalize(repository)
5497        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5498    if !visited.insert(canonical.clone()) {
5499        bail!("submodule recursion revisited {}", canonical.display());
5500    }
5501    // Classify ordinary paths in the listing itself. Listing every path here
5502    // and marking ignored paths in a second query misclassifies build output
5503    // deleted between queries as ordinary untracked work.
5504    let listed = run_git_bytes(
5505        repository,
5506        &["ls-files", "--others", "--exclude-standard", "-z"],
5507    )?;
5508    if !listed.is_empty() && !listed.ends_with(&[0]) {
5509        bail!(
5510            "git returned an unterminated untracked-file list for {}",
5511            repository.display()
5512        );
5513    }
5514
5515    for raw in listed
5516        .split(|byte| *byte == 0)
5517        .filter(|raw| !raw.is_empty())
5518    {
5519        let (relative, nested) = untracked_record(raw, "untracked")?;
5520        let from_root = prefix.join(&relative);
5521        if is_spar_own_path(&from_root) {
5522            continue;
5523        }
5524        let absolute = root.join(&from_root);
5525        let fingerprint = if nested {
5526            nested_repository_fingerprint(&absolute)?
5527        } else {
5528            ignored_file_fingerprint(&absolute)?
5529        };
5530        if files.insert(from_root.clone(), fingerprint).is_some() {
5531            bail!(
5532                "git returned the untracked path more than once: {:?}",
5533                from_root
5534            );
5535        }
5536    }
5537
5538    let ignored_listed = run_git_bytes(
5539        repository,
5540        &[
5541            "ls-files",
5542            "--others",
5543            "--ignored",
5544            "--exclude-standard",
5545            "-z",
5546        ],
5547    )?;
5548    if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
5549        bail!(
5550            "git returned an unterminated ignored-file list for {}",
5551            repository.display()
5552        );
5553    }
5554    // Two `ls-files` runs cannot be made to agree about a tree that is being
5555    // built in. A path named only by the second one was created between them,
5556    // which is a fact about the moment, not a corruption: record it and let the
5557    // comparison decide, rather than ending a run that had nothing wrong with
5558    // it.
5559    for raw in ignored_listed
5560        .split(|byte| *byte == 0)
5561        .filter(|raw| !raw.is_empty())
5562    {
5563        let (relative, nested) = untracked_record(raw, "ignored")?;
5564        let from_root = prefix.join(relative);
5565        if is_spar_own_path(&from_root) {
5566            continue;
5567        }
5568        if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
5569            let absolute = root.join(&from_root);
5570            let fingerprint = if nested {
5571                nested_repository_fingerprint(&absolute)?
5572            } else {
5573                ignored_file_fingerprint(&absolute)?
5574            };
5575            slot.insert(fingerprint);
5576        }
5577        if !ignored.insert(from_root.clone()) {
5578            bail!(
5579                "git returned the ignored path more than once: {:?}",
5580                from_root
5581            );
5582        }
5583    }
5584
5585    for link in gitlinks(repository)? {
5586        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
5587            continue;
5588        };
5589        collect_untracked_files(
5590            root,
5591            &submodule,
5592            &prefix.join(&link.path),
5593            visited,
5594            files,
5595            ignored,
5596        )?;
5597    }
5598    Ok(())
5599}
5600
5601fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
5602    let argv = git_without_automation_argv(args);
5603    proc::run_bytes(
5604        &argv,
5605        &ExecOpts::new()
5606            .cwd(cwd)
5607            .timeout_secs(30)
5608            .stop_descendants(true),
5609    )
5610}
5611
5612fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
5613    let argv = git_without_automation_argv(args);
5614    proc::run(
5615        &argv,
5616        &ExecOpts::new()
5617            .cwd(cwd)
5618            .timeout_secs(30)
5619            .stop_descendants(true),
5620    )
5621}
5622
5623fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
5624    let path = path.to_str().ok_or_else(|| {
5625        spar_err!(
5626            "cannot verify filtered content for a non-UTF-8 path in {}",
5627            cwd.display()
5628        )
5629    })?;
5630    let path_arg = format!("--path={path}");
5631    let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
5632    Ok(Sha256::digest(bytes).into())
5633}
5634
5635fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
5636    let relative = path_from_git_bytes(raw)?;
5637    if relative.is_absolute()
5638        || relative.components().any(|component| {
5639            matches!(
5640                component,
5641                std::path::Component::ParentDir
5642                    | std::path::Component::RootDir
5643                    | std::path::Component::Prefix(_)
5644            )
5645        })
5646    {
5647        bail!("git returned an unsafe {kind} path: {:?}", relative);
5648    }
5649    Ok(relative)
5650}
5651
5652/// Split one `ls-files --others` record into its path and whether Git reported
5653/// a nested repository rather than a single file.
5654///
5655/// Git never lists the contents of a repository inside the working tree, so a
5656/// checkout parked there, such as another of SPAR's own worktrees, arrives as
5657/// one record for the directory itself ending in a separator. Git writes that
5658/// separator on every platform. Trimming it keeps the recorded path equal to
5659/// the same path seen any other way.
5660fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
5661    let nested = raw.last() == Some(&b'/');
5662    let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
5663    if trimmed.is_empty() {
5664        bail!("git returned an empty {kind} path");
5665    }
5666    Ok((safe_git_path(trimmed, kind)?, nested))
5667}
5668
5669fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
5670    let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
5671    if !listed.is_empty() && !listed.ends_with(&[0]) {
5672        bail!(
5673            "git returned an unterminated index listing for {}",
5674            cwd.display()
5675        );
5676    }
5677    let mut entries = Vec::new();
5678    for record in listed
5679        .split(|byte| *byte == 0)
5680        .filter(|record| !record.is_empty())
5681    {
5682        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5683            bail!(
5684                "git returned a malformed index record for {}",
5685                cwd.display()
5686            );
5687        };
5688        let header = &record[..tab];
5689        let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
5690        if fields.len() != 3 {
5691            bail!(
5692                "git returned a malformed index header for {}",
5693                cwd.display()
5694            );
5695        }
5696        if fields[2] != b"0" {
5697            continue;
5698        }
5699        let mode = std::str::from_utf8(fields[0])
5700            .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
5701            .to_string();
5702        let oid = std::str::from_utf8(fields[1])
5703            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5704            .to_string();
5705        entries.push(IndexEntry {
5706            path: safe_git_path(&record[tab + 1..], "index")?,
5707            mode,
5708            oid,
5709        });
5710    }
5711    Ok(entries)
5712}
5713
5714/// Share Git's complete ignore policy between call checkpoints and handoffs.
5715fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
5716    let listed = run_git_bytes(
5717        cwd,
5718        &[
5719            "ls-files",
5720            "--others",
5721            "--exclude-standard",
5722            "-z",
5723            "--",
5724            ".gitattributes",
5725            ":(glob)**/.gitattributes",
5726        ],
5727    )?;
5728    if !listed.is_empty() && !listed.ends_with(&[0]) {
5729        bail!(
5730            "git returned an unterminated attribute-file listing for {}",
5731            cwd.display()
5732        );
5733    }
5734    listed
5735        .split(|byte| *byte == 0)
5736        .filter(|record| !record.is_empty())
5737        .map(|raw| safe_git_path(raw, "attribute"))
5738        .collect()
5739}
5740
5741fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
5742    if !untracked_attribute_paths(cwd)?.is_empty() {
5743        return Ok(true);
5744    }
5745
5746    let index = index_entries(cwd)?
5747        .into_iter()
5748        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5749        .map(|entry| (entry.path, (entry.mode, entry.oid)))
5750        .collect::<BTreeMap<_, _>>();
5751    let head = tree_entries(cwd, "HEAD")?
5752        .into_iter()
5753        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5754        .map(|entry| (entry.path, (entry.mode, entry.oid)))
5755        .collect::<BTreeMap<_, _>>();
5756    if index != head {
5757        return Ok(true);
5758    }
5759
5760    let effective = check_attributes(cwd, index.keys().cloned())?;
5761    let config = CheckoutConfig::read(cwd)?;
5762    for (path, (_mode, oid)) in index {
5763        let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
5764            return Ok(true);
5765        };
5766        let attributes = effective
5767            .get(&path)
5768            .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
5769        if allows_expected_crlf(&config, attributes)? {
5770            if worktree.mode == "120000" {
5771                return Ok(true);
5772            }
5773            let (normalized, every_lf_was_crlf) =
5774                normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
5775            if !every_lf_was_crlf || normalized != oid {
5776                return Ok(true);
5777            }
5778        } else if worktree.raw_oid != oid {
5779            return Ok(true);
5780        }
5781    }
5782    Ok(false)
5783}
5784
5785fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
5786    Ok(index_entries(cwd)?
5787        .into_iter()
5788        .filter(|entry| entry.mode == "160000")
5789        .map(|entry| Gitlink {
5790            path: entry.path,
5791            oid: entry.oid,
5792        })
5793        .collect())
5794}
5795
5796fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
5797    let mut tracked = BTreeMap::new();
5798    for entry in index_entries(cwd)? {
5799        if entry.mode == "160000" {
5800            continue;
5801        }
5802        let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
5803        tracked.insert(
5804            entry.path,
5805            TrackedEntry {
5806                index_mode: entry.mode,
5807                index_oid: entry.oid,
5808                worktree,
5809            },
5810        );
5811    }
5812    Ok(tracked)
5813}
5814
5815fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
5816    let metadata = match std::fs::symlink_metadata(path) {
5817        Ok(metadata) => metadata,
5818        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
5819        Err(e) => {
5820            return Err(spar_err!(
5821                "could not inspect tracked file {}: {e}",
5822                path.display()
5823            ))
5824        }
5825    };
5826    let mut fingerprint = Sha256::new();
5827    if metadata.file_type().is_symlink() {
5828        let target = std::fs::read_link(path)
5829            .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
5830        let bytes = os_str_bytes(target.as_os_str())?;
5831        fingerprint.update(b"symlink\0");
5832        fingerprint.update(&bytes);
5833        let content = Sha256::digest(&bytes).into();
5834        return Ok(Some(WorktreeFile {
5835            mode: "120000".to_string(),
5836            #[cfg(unix)]
5837            permissions: 0,
5838            raw_oid: git_blob_oid(oid_len, &bytes)?,
5839            fingerprint: fingerprint.finalize().into(),
5840            content,
5841        }));
5842    }
5843    if !metadata.is_file() {
5844        bail!("tracked path {} is not a file or symlink", path.display());
5845    }
5846
5847    let mut options = OpenOptions::new();
5848    options.read(true);
5849    #[cfg(unix)]
5850    {
5851        use std::os::unix::fs::OpenOptionsExt;
5852        options.custom_flags(libc::O_NOFOLLOW);
5853    }
5854    let mut file = options
5855        .open(path)
5856        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5857    let before = file
5858        .metadata()
5859        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5860    let mode = tracked_file_mode(&before);
5861    #[cfg(unix)]
5862    let permissions = {
5863        use std::os::unix::fs::MetadataExt;
5864        before.mode() & 0o7777
5865    };
5866    fingerprint.update(b"file\0");
5867    fingerprint.update(mode.as_bytes());
5868    #[cfg(unix)]
5869    fingerprint.update(permissions.to_le_bytes());
5870    fingerprint.update(before.len().to_le_bytes());
5871    let mut content = Sha256::new();
5872    let header = format!("blob {}\0", before.len());
5873    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5874    let mut buf = [0u8; 64 * 1024];
5875    loop {
5876        let read = file
5877            .read(&mut buf)
5878            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5879        if read == 0 {
5880            break;
5881        }
5882        fingerprint.update(&buf[..read]);
5883        content.update(&buf[..read]);
5884        object.update(&buf[..read]);
5885    }
5886    let after = file
5887        .metadata()
5888        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5889    if before.len() != after.len()
5890        || before.modified().ok() != after.modified().ok()
5891        || before.permissions() != after.permissions()
5892    {
5893        bail!(
5894            "tracked file {} changed while it was being inspected",
5895            path.display()
5896        );
5897    }
5898    let current = std::fs::symlink_metadata(path)
5899        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5900    if !same_file(&after, &current) {
5901        bail!(
5902            "tracked file {} was replaced while it was being inspected",
5903            path.display()
5904        );
5905    }
5906    Ok(Some(WorktreeFile {
5907        mode,
5908        #[cfg(unix)]
5909        permissions,
5910        raw_oid: object.finish(),
5911        fingerprint: fingerprint.finalize().into(),
5912        content: content.finalize().into(),
5913    }))
5914}
5915
5916fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
5917    let metadata = std::fs::symlink_metadata(path)
5918        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5919    let mut digest = Sha256::new();
5920    if metadata.file_type().is_symlink() {
5921        digest.update(b"symlink\0");
5922        let target = std::fs::read_link(path)
5923            .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
5924        digest.update(os_str_bytes(target.as_os_str())?);
5925        return Ok(digest.finalize().into());
5926    }
5927    if !metadata.is_file() {
5928        bail!("attribute path {} is not a file or symlink", path.display());
5929    }
5930    let mut options = OpenOptions::new();
5931    options.read(true);
5932    #[cfg(unix)]
5933    {
5934        use std::os::unix::fs::OpenOptionsExt;
5935        options.custom_flags(libc::O_NOFOLLOW);
5936    }
5937    let mut file = options
5938        .open(path)
5939        .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5940    let before = file
5941        .metadata()
5942        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5943    digest.update(b"file\0");
5944    let mut buf = [0u8; 64 * 1024];
5945    loop {
5946        let read = file
5947            .read(&mut buf)
5948            .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5949        if read == 0 {
5950            break;
5951        }
5952        digest.update(&buf[..read]);
5953    }
5954    let after = file
5955        .metadata()
5956        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5957    let current = std::fs::symlink_metadata(path)
5958        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5959    if before.len() != after.len()
5960        || before.modified().ok() != after.modified().ok()
5961        || !same_file(&after, &current)
5962    {
5963        bail!(
5964            "attribute file {} changed while it was being inspected",
5965            path.display()
5966        );
5967    }
5968    Ok(digest.finalize().into())
5969}
5970
5971enum ObjectHasher {
5972    Sha1(Sha1),
5973    Sha256(Sha256),
5974}
5975
5976impl ObjectHasher {
5977    fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
5978        let mut hasher = match oid_len {
5979            40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
5980            64 => Self::Sha256(Sha256::new()),
5981            _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
5982        };
5983        hasher.update(header);
5984        Ok(hasher)
5985    }
5986
5987    fn update(&mut self, bytes: &[u8]) {
5988        match self {
5989            Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
5990            Self::Sha256(hasher) => hasher.update(bytes),
5991        }
5992    }
5993
5994    fn finish(self) -> String {
5995        let bytes = match self {
5996            Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
5997            Self::Sha256(hasher) => hasher.finalize().to_vec(),
5998        };
5999        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
6000    }
6001}
6002
6003fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
6004    let header = format!("blob {}\0", bytes.len());
6005    let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
6006    hasher.update(bytes);
6007    Ok(hasher.finish())
6008}
6009
6010fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
6011    let mut first = open_regular_file(path)?;
6012    let first_before = first
6013        .metadata()
6014        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6015    let mut raw_len = 0u64;
6016    let mut crlf_pairs = 0u64;
6017    let mut previous_was_cr = false;
6018    let mut every_lf_was_crlf = true;
6019    let mut buf = [0u8; 64 * 1024];
6020    loop {
6021        let read = first
6022            .read(&mut buf)
6023            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6024        if read == 0 {
6025            break;
6026        }
6027        raw_len = raw_len
6028            .checked_add(read as u64)
6029            .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
6030        for byte in &buf[..read] {
6031            if *byte == b'\n' {
6032                if previous_was_cr {
6033                    crlf_pairs += 1;
6034                } else {
6035                    every_lf_was_crlf = false;
6036                }
6037            }
6038            previous_was_cr = *byte == b'\r';
6039        }
6040    }
6041    let first_after = first
6042        .metadata()
6043        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6044    let current = std::fs::symlink_metadata(path)
6045        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6046    if raw_len != first_before.len()
6047        || !stable_file_metadata(&first_before, &first_after)
6048        || !stable_file_metadata(&first_after, &current)
6049    {
6050        bail!(
6051            "tracked file {} changed while line endings were inspected",
6052            path.display()
6053        );
6054    }
6055
6056    let normalized_len = raw_len
6057        .checked_sub(crlf_pairs)
6058        .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
6059    let header = format!("blob {normalized_len}\0");
6060    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
6061    let mut second = open_regular_file(path)?;
6062    let second_before = second
6063        .metadata()
6064        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6065    if !stable_file_metadata(&first_after, &second_before) {
6066        bail!(
6067            "tracked file {} changed between line-ending checks",
6068            path.display()
6069        );
6070    }
6071    let mut pending_cr = false;
6072    loop {
6073        let read = second
6074            .read(&mut buf)
6075            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6076        if read == 0 {
6077            break;
6078        }
6079        for byte in &buf[..read] {
6080            if pending_cr {
6081                if *byte == b'\n' {
6082                    object.update(b"\n");
6083                    pending_cr = false;
6084                    continue;
6085                }
6086                object.update(b"\r");
6087                pending_cr = false;
6088            }
6089            if *byte == b'\r' {
6090                pending_cr = true;
6091            } else {
6092                object.update(std::slice::from_ref(byte));
6093            }
6094        }
6095    }
6096    if pending_cr {
6097        object.update(b"\r");
6098    }
6099    let second_after = second
6100        .metadata()
6101        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6102    let current = std::fs::symlink_metadata(path)
6103        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6104    if !stable_file_metadata(&second_before, &second_after)
6105        || !stable_file_metadata(&second_after, &current)
6106    {
6107        bail!(
6108            "tracked file {} changed while line endings were hashed",
6109            path.display()
6110        );
6111    }
6112    Ok((object.finish(), every_lf_was_crlf))
6113}
6114
6115fn open_regular_file(path: &Path) -> Result<std::fs::File> {
6116    let mut options = OpenOptions::new();
6117    options.read(true);
6118    #[cfg(unix)]
6119    {
6120        use std::os::unix::fs::OpenOptionsExt;
6121        options.custom_flags(libc::O_NOFOLLOW);
6122    }
6123    let file = options
6124        .open(path)
6125        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6126    let metadata = file
6127        .metadata()
6128        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6129    if !metadata.is_file() {
6130        bail!("tracked path {} is not a regular file", path.display());
6131    }
6132    Ok(file)
6133}
6134
6135fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6136    if !same_file(left, right)
6137        || left.len() != right.len()
6138        || left.modified().ok() != right.modified().ok()
6139        || left.permissions() != right.permissions()
6140    {
6141        return false;
6142    }
6143    #[cfg(unix)]
6144    {
6145        use std::os::unix::fs::MetadataExt;
6146        left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
6147    }
6148    #[cfg(not(unix))]
6149    {
6150        left.created().ok() == right.created().ok()
6151    }
6152}
6153
6154/// How many paths one `check-attr` call is asked about.
6155///
6156/// `check-attr` answers with three NUL fields per attribute per path, so the
6157/// output is several times the input. A whole monorepo in one call is megabytes
6158/// in both directions, and the 30 second timeout below is a timeout on the
6159/// batch. Batching keeps each call small enough to be honest about.
6160const ATTRIBUTE_BATCH: usize = 1000;
6161
6162fn check_attributes(
6163    cwd: &Path,
6164    paths: impl IntoIterator<Item = PathBuf>,
6165) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
6166    let paths = paths.into_iter().collect::<BTreeSet<_>>();
6167    if paths.is_empty() {
6168        return Ok(BTreeMap::new());
6169    }
6170    let ordered = paths.into_iter().collect::<Vec<_>>();
6171    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
6172    for batch in ordered.chunks(ATTRIBUTE_BATCH) {
6173        values.extend(check_attribute_batch(cwd, batch)?);
6174    }
6175    Ok(values)
6176}
6177
6178fn check_attribute_batch(
6179    cwd: &Path,
6180    batch: &[PathBuf],
6181) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
6182    const NAMES: [&str; 6] = [
6183        "filter",
6184        "working-tree-encoding",
6185        "ident",
6186        "text",
6187        "eol",
6188        "crlf",
6189    ];
6190    let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
6191    let mut input = String::new();
6192    for path in &paths {
6193        let path = path.to_str().ok_or_else(|| {
6194            spar_err!(
6195                "cannot inspect attributes for a non-UTF-8 path in {}",
6196                cwd.display()
6197            )
6198        })?;
6199        input.push_str(path);
6200        input.push('\0');
6201    }
6202    let argv = git_without_automation_argv(&[
6203        "check-attr",
6204        "-z",
6205        "--cached",
6206        "--stdin",
6207        "filter",
6208        "working-tree-encoding",
6209        "ident",
6210        "text",
6211        "eol",
6212        "crlf",
6213    ]);
6214    let output = proc::run_bytes(
6215        &argv,
6216        &ExecOpts::new()
6217            .cwd(cwd)
6218            .timeout_secs(30)
6219            .stdin(input)
6220            .stop_descendants(true),
6221    )?;
6222    if !output.is_empty() && !output.ends_with(&[0]) {
6223        bail!(
6224            "git returned an unterminated attribute result for {}",
6225            cwd.display()
6226        );
6227    }
6228    let fields = output
6229        .split(|byte| *byte == 0)
6230        .filter(|field| !field.is_empty())
6231        .collect::<Vec<_>>();
6232    if fields.len() != paths.len() * NAMES.len() * 3 {
6233        bail!(
6234            "git returned an unexpected attribute result for {}",
6235            cwd.display()
6236        );
6237    }
6238    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
6239    for record in fields.chunks_exact(3) {
6240        let path = safe_git_path(record[0], "attribute")?;
6241        if !paths.contains(&path) {
6242            bail!(
6243                "git returned attributes for the wrong path in {}",
6244                cwd.display()
6245            );
6246        }
6247        let name = std::str::from_utf8(record[1])
6248            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
6249        let value = std::str::from_utf8(record[2])
6250            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
6251        values
6252            .entry(path)
6253            .or_default()
6254            .insert(name.to_string(), value.to_string());
6255    }
6256    if paths.iter().any(|path| {
6257        values
6258            .get(path)
6259            .is_none_or(|attributes| attributes.len() != NAMES.len())
6260    }) {
6261        bail!(
6262            "git omitted an attribute result for a tracked path in {}",
6263            cwd.display()
6264        );
6265    }
6266    Ok(values)
6267}
6268
6269fn attribute_is_active(value: Option<&String>) -> bool {
6270    !matches!(
6271        value.map(String::as_str),
6272        None | Some("unspecified") | Some("unset")
6273    )
6274}
6275
6276fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
6277    attribute_is_active(values.get("filter"))
6278        || attribute_is_active(values.get("working-tree-encoding"))
6279}
6280
6281/// The checkout settings a per-file line-ending decision depends on.
6282///
6283/// These are constant for a worktree, but the checks that read them run once
6284/// per tracked file, and each read was its own `git config` process. A
6285/// thousand-file repository with no `.gitattributes` takes every one of those
6286/// branches, which is half a minute of process spawning per scan, and the sweep
6287/// before a run scans every finished worktree more than once. Read them here,
6288/// once, and hand them down.
6289struct CheckoutConfig {
6290    autocrlf: Option<String>,
6291    eol: Option<String>,
6292    symlinks: Option<bool>,
6293}
6294
6295impl CheckoutConfig {
6296    fn read(cwd: &Path) -> Result<Self> {
6297        Ok(Self {
6298            autocrlf: git_config_value(cwd, "core.autocrlf")?,
6299            eol: git_config_value(cwd, "core.eol")?,
6300            symlinks: git_config_bool(cwd, "core.symlinks")?,
6301        })
6302    }
6303
6304    fn autocrlf_is_true(&self) -> bool {
6305        self.autocrlf.as_ref().is_some_and(|value| {
6306            matches!(
6307                value.to_ascii_lowercase().as_str(),
6308                "true" | "yes" | "on" | "1"
6309            )
6310        })
6311    }
6312
6313    fn eol_is(&self, wanted: &str) -> bool {
6314        self.eol
6315            .as_ref()
6316            .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
6317    }
6318}
6319
6320fn path_has_ambiguous_transform(
6321    config: &CheckoutConfig,
6322    values: &BTreeMap<String, String>,
6323) -> Result<bool> {
6324    if path_has_external_transform(values)
6325        || attribute_is_active(values.get("ident"))
6326        || attribute_is_active(values.get("crlf"))
6327    {
6328        return Ok(true);
6329    }
6330    let text = values.get("text").map(String::as_str);
6331    let eol = values.get("eol").map(String::as_str);
6332    if text == Some("auto") {
6333        return Ok(true);
6334    }
6335    if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
6336        || !matches!(
6337            eol,
6338            Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
6339        )
6340    {
6341        return Ok(true);
6342    }
6343    if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
6344        return Ok(config.autocrlf_is_true());
6345    }
6346    Ok(false)
6347}
6348
6349fn allows_expected_crlf(
6350    config: &CheckoutConfig,
6351    values: &BTreeMap<String, String>,
6352) -> Result<bool> {
6353    if path_has_external_transform(values)
6354        || attribute_is_active(values.get("ident"))
6355        || attribute_is_active(values.get("crlf"))
6356    {
6357        return Ok(false);
6358    }
6359    let text = values.get("text").map(String::as_str);
6360    let eol = values.get("eol").map(String::as_str);
6361    if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
6362        return Ok(false);
6363    }
6364    if eol == Some("crlf") {
6365        return Ok(true);
6366    }
6367    if text != Some("set") {
6368        return Ok(false);
6369    }
6370    if let Some(autocrlf) = config.autocrlf.as_deref() {
6371        match autocrlf.to_ascii_lowercase().as_str() {
6372            "true" | "yes" | "on" | "1" => return Ok(true),
6373            "input" => return Ok(false),
6374            _ => {}
6375        }
6376    }
6377    if config.eol_is("crlf") {
6378        return Ok(true);
6379    }
6380    #[cfg(windows)]
6381    if config.eol.is_none() || config.eol_is("native") {
6382        return Ok(true);
6383    }
6384    Ok(false)
6385}
6386
6387fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
6388    let argv = git_without_automation_argv(&["config", "--get", key]);
6389    let output = proc::exec(
6390        &argv,
6391        &ExecOpts::new()
6392            .cwd(cwd)
6393            .timeout_secs(30)
6394            .check(false)
6395            .stop_descendants(true),
6396    )?;
6397    match output.code {
6398        0 => Ok(Some(output.stdout.trim().to_string())),
6399        1 => Ok(None),
6400        _ => bail!(
6401            "could not read Git configuration in {}: {}",
6402            cwd.display(),
6403            output.stderr.trim()
6404        ),
6405    }
6406}
6407
6408fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
6409    let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
6410    let output = proc::exec(
6411        &argv,
6412        &ExecOpts::new()
6413            .cwd(cwd)
6414            .timeout_secs(30)
6415            .check(false)
6416            .stop_descendants(true),
6417    )?;
6418    match output.code {
6419        0 if output.stdout.trim() == "true" => Ok(Some(true)),
6420        0 if output.stdout.trim() == "false" => Ok(Some(false)),
6421        0 => bail!(
6422            "git returned an invalid boolean for {key} in {}",
6423            cwd.display()
6424        ),
6425        1 => Ok(None),
6426        _ => bail!(
6427            "could not read Git configuration in {}: {}",
6428            cwd.display(),
6429            output.stderr.trim()
6430        ),
6431    }
6432}
6433
6434#[cfg(unix)]
6435fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
6436    use std::os::unix::fs::PermissionsExt;
6437    if metadata.permissions().mode() & 0o111 == 0 {
6438        "100644".to_string()
6439    } else {
6440        "100755".to_string()
6441    }
6442}
6443
6444#[cfg(not(unix))]
6445fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
6446    "100644".to_string()
6447}
6448
6449fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
6450    let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
6451    if !listed.is_empty() && !listed.ends_with(&[0]) {
6452        bail!(
6453            "git returned an unterminated tree listing for {}",
6454            cwd.display()
6455        );
6456    }
6457    let mut entries = Vec::new();
6458    for record in listed
6459        .split(|byte| *byte == 0)
6460        .filter(|record| !record.is_empty())
6461    {
6462        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6463            bail!("git returned a malformed tree record for {}", cwd.display());
6464        };
6465        let fields = record[..tab]
6466            .split(|byte| *byte == b' ')
6467            .collect::<Vec<_>>();
6468        if fields.len() != 3 {
6469            bail!("git returned a malformed tree header for {}", cwd.display());
6470        }
6471        let mode = std::str::from_utf8(fields[0])
6472            .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
6473            .to_string();
6474        let oid = std::str::from_utf8(fields[2])
6475            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6476            .to_string();
6477        entries.push(IndexEntry {
6478            path: safe_git_path(&record[tab + 1..], "tree")?,
6479            mode,
6480            oid,
6481        });
6482    }
6483    Ok(entries)
6484}
6485
6486fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
6487    Ok(tree_entries(cwd, "HEAD")?
6488        .into_iter()
6489        .filter(|entry| entry.mode == "160000")
6490        .map(|entry| (entry.path, entry.oid))
6491        .collect())
6492}
6493
6494fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
6495    let head = head_gitlinks(cwd)?;
6496    let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
6497        .into_iter()
6498        .map(|link| (link.path, link.oid))
6499        .collect();
6500    let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
6501    paths.extend(index.keys().cloned());
6502    Ok(paths
6503        .into_iter()
6504        .filter(|path| head.get(path) != index.get(path))
6505        .collect())
6506}
6507
6508fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
6509    let path = parent.join(relative);
6510    let metadata = match std::fs::symlink_metadata(&path) {
6511        Ok(metadata) => metadata,
6512        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6513        Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6514    };
6515    if !metadata.is_dir() {
6516        bail!("the gitlink at {} is not a directory", path.display());
6517    }
6518    let canonical = std::fs::canonicalize(&path)
6519        .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
6520    if canonical != path {
6521        bail!(
6522            "the gitlink at {} resolves through a symlink",
6523            path.display()
6524        );
6525    }
6526    if !path.join(".git").exists() {
6527        let empty = std::fs::read_dir(&path)
6528            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
6529            .next()
6530            .is_none();
6531        if empty {
6532            return Ok(None);
6533        }
6534        bail!(
6535            "the uninitialized gitlink at {} contains local files",
6536            path.display()
6537        );
6538    }
6539    let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
6540    if inside.trim() != "true" {
6541        bail!("the gitlink at {} is not a worktree", path.display());
6542    }
6543    let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
6544    let top = std::fs::canonicalize(top.trim()).map_err(|e| {
6545        spar_err!(
6546            "could not resolve the gitlink top level at {}: {e}",
6547            path.display()
6548        )
6549    })?;
6550    if top != canonical {
6551        bail!(
6552            "the gitlink at {} belongs to a different worktree",
6553            path.display()
6554        );
6555    }
6556    Ok(Some(canonical))
6557}
6558
6559/// A Git entry the outer repository cannot account for.
6560///
6561/// A checkout Git itself reports as a nested repository is expected. Its
6562/// directory is recorded by identity in the untracked snapshot and keeps the
6563/// worktree from being released, so a reviewer that clones a reproduction into
6564/// ignored build output neither loses that clone nor fails the run over it.
6565/// The scan does not descend into one, because what is under it belongs to
6566/// that repository.
6567///
6568/// A Git entry Git does not report is the one worth stopping for. Running
6569/// `git init` inside a directory that already holds tracked files leaves
6570/// nothing in any listing while making every file written under it invisible
6571/// to the outer worktree, so SPAR can no longer prove what is there.
6572fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
6573    let root = std::fs::canonicalize(cwd)
6574        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6575    let mut allowed = BTreeSet::from([root.join(".git")]);
6576    let mut checkouts = BTreeSet::new();
6577    let mut repositories = vec![root.clone()];
6578    let mut visited = BTreeSet::new();
6579    while let Some(repository) = repositories.pop() {
6580        let canonical = std::fs::canonicalize(&repository)
6581            .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6582        if !visited.insert(canonical.clone()) {
6583            bail!("submodule recursion revisited {}", canonical.display());
6584        }
6585        for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
6586            let (path, nested) = untracked_record(&raw, "untracked")?;
6587            if nested {
6588                checkouts.insert(canonical.join(path));
6589            }
6590        }
6591        for link in gitlinks(&canonical)? {
6592            let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
6593                continue;
6594            };
6595            allowed.insert(submodule.join(".git"));
6596            repositories.push(submodule);
6597        }
6598    }
6599
6600    // A build deletes its own directories while this walks them. A directory
6601    // that is gone holds no Git entry, so skipping it answers the question
6602    // being asked; erroring instead ended runs whose worktrees were fine.
6603    let scan_root = root.clone();
6604    let mut directories = vec![root];
6605    while let Some(directory) = directories.pop() {
6606        let entries = match std::fs::read_dir(&directory) {
6607            Ok(entries) => entries,
6608            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6609            Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6610        };
6611        for entry in entries {
6612            let entry = match entry {
6613                Ok(entry) => entry,
6614                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6615                Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6616            };
6617            let path = entry.path();
6618            if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
6619                continue;
6620            }
6621            if entry.file_name() == OsStr::new(".git") {
6622                if !allowed.contains(&path) {
6623                    return Ok(Some(path));
6624                }
6625                continue;
6626            }
6627            let kind = match entry.file_type() {
6628                Ok(kind) => kind,
6629                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6630                Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6631            };
6632            if kind.is_dir() && !checkouts.contains(&path) {
6633                directories.push(path);
6634            }
6635        }
6636    }
6637    Ok(None)
6638}
6639
6640pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
6641    if let Some(path) = unexpected_nested_git_entry(cwd)? {
6642        bail!(
6643            "the worktree contains a Git entry at {} that Git does not report as a nested \
6644             checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
6645             prove they are unchanged.",
6646            path.display()
6647        );
6648    }
6649    let root = std::fs::canonicalize(cwd)
6650        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6651    let mut repositories = BTreeMap::new();
6652    let mut visited = BTreeSet::new();
6653    collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
6654    Ok(GitState { repositories })
6655}
6656
6657fn collect_git_state(
6658    repository: &Path,
6659    prefix: &Path,
6660    visited: &mut BTreeSet<PathBuf>,
6661    repositories: &mut BTreeMap<PathBuf, RepositoryState>,
6662) -> Result<()> {
6663    let canonical = std::fs::canonicalize(repository)
6664        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6665    if !visited.insert(canonical.clone()) {
6666        bail!("submodule recursion revisited {}", canonical.display());
6667    }
6668    let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6669    let head = head.trim().to_string();
6670    if head.is_empty() {
6671        bail!("git returned an empty head for {}", repository.display());
6672    }
6673    let unsafe_index_flags = unsafe_index_flags(repository)?;
6674    let tracked = tracked_entries(repository)?;
6675    let gitlinks = gitlinks(repository)?;
6676    if repositories
6677        .insert(
6678            prefix.to_path_buf(),
6679            RepositoryState {
6680                head,
6681                unsafe_index_flags,
6682                tracked,
6683                gitlinks: gitlinks
6684                    .iter()
6685                    .map(|link| (link.path.clone(), link.oid.clone()))
6686                    .collect(),
6687            },
6688        )
6689        .is_some()
6690    {
6691        bail!("Git state contains duplicate repository path {:?}", prefix);
6692    }
6693
6694    for link in gitlinks {
6695        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6696            continue;
6697        };
6698        collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
6699    }
6700    Ok(())
6701}
6702
6703fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
6704    let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
6705    if !listed.is_empty() && !listed.ends_with(&[0]) {
6706        bail!(
6707            "git returned an unterminated index-flag listing for {}",
6708            cwd.display()
6709        );
6710    }
6711    let mut unsafe_records = Vec::new();
6712    for record in listed
6713        .split(|byte| *byte == 0)
6714        .filter(|record| !record.is_empty())
6715    {
6716        if record.len() < 3 || record[1] != b' ' {
6717            bail!(
6718                "git returned a malformed index-flag record for {}",
6719                cwd.display()
6720            );
6721        }
6722        if record[0] != b'H' {
6723            unsafe_records.extend_from_slice(record);
6724            unsafe_records.push(0);
6725        }
6726    }
6727    Ok(unsafe_records)
6728}
6729
6730pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
6731    safe_git_state(cwd).map(|_| ())
6732}
6733
6734pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
6735    let state = git_state(cwd)?;
6736    if let Some((path, _repository)) = state
6737        .repositories
6738        .iter()
6739        .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
6740    {
6741        let label = if path.as_os_str().is_empty() {
6742            cwd.to_path_buf()
6743        } else {
6744            cwd.join(path)
6745        };
6746        bail!(
6747            "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
6748             SPAR cannot prove the working files are unchanged, so it was kept.",
6749            label.display()
6750        );
6751    }
6752    Ok(state)
6753}
6754
6755fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
6756    repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
6757}
6758
6759fn repository_has_recoverable_work_with(
6760    cwd: &Path,
6761    include_ignored: bool,
6762    disposable: Disposable,
6763) -> Result<bool> {
6764    if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
6765        return Ok(true);
6766    }
6767    let mut visited = BTreeSet::new();
6768    repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
6769}
6770
6771fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
6772    let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
6773    let git_dir = PathBuf::from(git_dir.trim());
6774    let git_dir = if git_dir.is_absolute() {
6775        git_dir
6776    } else {
6777        cwd.join(git_dir)
6778    };
6779    let git_dir = std::fs::canonicalize(&git_dir)
6780        .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
6781    match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
6782        Ok(_) => return Ok(true),
6783        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6784        Err(error) => {
6785            return Err(spar_err!(
6786                "could not inspect per-worktree configuration in {}: {error}",
6787                git_dir.display()
6788            ))
6789        }
6790    }
6791
6792    let orig_head = git_dir.join("ORIG_HEAD");
6793    match std::fs::symlink_metadata(&orig_head) {
6794        Ok(metadata) if metadata.is_file() => {
6795            let oid = std::fs::read_to_string(&orig_head)
6796                .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
6797            let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
6798                return Ok(true);
6799            };
6800            if !commit_has_shared_ref(cwd, &commit)? {
6801                return Ok(true);
6802            }
6803        }
6804        Ok(_) => return Ok(true),
6805        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6806        Err(error) => {
6807            return Err(spar_err!(
6808                "could not inspect {}: {error}",
6809                orig_head.display()
6810            ))
6811        }
6812    }
6813
6814    let edit_message = git_dir.join("COMMIT_EDITMSG");
6815    match std::fs::symlink_metadata(&edit_message) {
6816        Ok(metadata) if metadata.is_file() => {
6817            let draft = std::fs::read(&edit_message)
6818                .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
6819            if draft != head_commit_message(cwd)? {
6820                return Ok(true);
6821            }
6822        }
6823        Ok(_) => return Ok(true),
6824        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6825        Err(error) => {
6826            return Err(spar_err!(
6827                "could not inspect {}: {error}",
6828                edit_message.display()
6829            ))
6830        }
6831    }
6832
6833    if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
6834        return Ok(true);
6835    }
6836
6837    let local_refs = run_git_bytes(
6838        cwd,
6839        &[
6840            "for-each-ref",
6841            "--format=%(refname)",
6842            "refs/worktree",
6843            "refs/bisect",
6844            "refs/rewritten",
6845        ],
6846    )?;
6847    if !local_refs.is_empty() {
6848        return Ok(true);
6849    }
6850
6851    for entry in std::fs::read_dir(&git_dir)
6852        .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
6853    {
6854        let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
6855        let known = matches!(
6856            entry.file_name().to_str(),
6857            Some(
6858                "HEAD"
6859                    | "ORIG_HEAD"
6860                    | "COMMIT_EDITMSG"
6861                    | "commondir"
6862                    | "gitdir"
6863                    | "index"
6864                    | "logs"
6865                    | "refs"
6866            )
6867        );
6868        if !known {
6869            return Ok(true);
6870        }
6871    }
6872
6873    let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6874    if !commit_has_shared_ref(cwd, head.trim())? {
6875        return Ok(true);
6876    }
6877    Ok(false)
6878}
6879
6880fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
6881    let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
6882    let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
6883        bail!(
6884            "git returned a commit without a message separator in {}",
6885            cwd.display()
6886        );
6887    };
6888    Ok(commit[split + 2..].to_vec())
6889}
6890
6891fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
6892    let metadata = match std::fs::symlink_metadata(logs) {
6893        Ok(metadata) => metadata,
6894        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6895        Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
6896    };
6897    if !metadata.is_dir() {
6898        return Ok(true);
6899    }
6900    let mut files = Vec::new();
6901    let mut directories = vec![logs.to_path_buf()];
6902    while let Some(directory) = directories.pop() {
6903        for entry in std::fs::read_dir(&directory)
6904            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
6905        {
6906            let entry =
6907                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
6908            let path = entry.path();
6909            let kind = entry
6910                .file_type()
6911                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
6912            if kind.is_dir() {
6913                directories.push(path);
6914            } else if kind.is_file() {
6915                files.push(path);
6916            } else {
6917                return Ok(true);
6918            }
6919        }
6920    }
6921
6922    let mut commits = BTreeSet::new();
6923    for path in files {
6924        if !collect_reflog_commits(cwd, &path, &mut commits)? {
6925            return Ok(true);
6926        }
6927    }
6928    for commit in commits {
6929        if !commit_has_shared_ref(cwd, &commit)? {
6930            return Ok(true);
6931        }
6932    }
6933    Ok(false)
6934}
6935
6936/// Every commit named by one ref's common reflog must survive deletion of that
6937/// ref. Ancestors of a durable current tip survive with the tip; divergent
6938/// entries need another shared ref of their own.
6939fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
6940    let common = common_git_dir(cwd)?;
6941    let reflog = common.join("logs").join(refname);
6942    let metadata = match std::fs::symlink_metadata(&reflog) {
6943        Ok(metadata) => metadata,
6944        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
6945        Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
6946    };
6947    if !metadata.is_file() {
6948        return Ok(false);
6949    }
6950    let mut commits = BTreeSet::new();
6951    if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
6952        return Ok(false);
6953    }
6954    for commit in commits {
6955        if is_ancestor(cwd, &commit, durable_tip)?
6956            || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
6957        {
6958            continue;
6959        }
6960        return Ok(false);
6961    }
6962    Ok(true)
6963}
6964
6965fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
6966    let file = std::fs::File::open(path)
6967        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6968    for line in std::io::BufReader::new(file).lines() {
6969        let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6970        let mut fields = line.splitn(3, ' ');
6971        let Some(old) = fields.next() else {
6972            return Ok(false);
6973        };
6974        let Some(new) = fields.next() else {
6975            return Ok(false);
6976        };
6977        if fields.next().is_none() {
6978            return Ok(false);
6979        }
6980        for oid in [old, new] {
6981            if oid.bytes().all(|byte| byte == b'0') {
6982                continue;
6983            }
6984            let Some(commit) = resolve_optional_commit(cwd, oid)? else {
6985                return Ok(false);
6986            };
6987            commits.insert(commit);
6988        }
6989    }
6990    Ok(true)
6991}
6992
6993fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
6994    let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
6995    let path = PathBuf::from(raw.trim());
6996    let path = if path.is_absolute() {
6997        path
6998    } else {
6999        cwd.join(path)
7000    };
7001    std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
7002}
7003
7004fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
7005    let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
7006    let output = proc::exec(
7007        &argv,
7008        &ExecOpts::new()
7009            .cwd(cwd)
7010            .timeout_secs(30)
7011            .check(false)
7012            .stop_descendants(true),
7013    )?;
7014    match output.code {
7015        0 => Ok(true),
7016        1 => Ok(false),
7017        _ => bail!("{}", proc::failure_message(&argv, &output)),
7018    }
7019}
7020
7021fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
7022    let commit = format!("{oid}^{{commit}}");
7023    let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
7024    let output = proc::exec(
7025        &argv,
7026        &ExecOpts::new()
7027            .cwd(cwd)
7028            .timeout_secs(30)
7029            .check(false)
7030            .stop_descendants(true),
7031    )?;
7032    if output.code != 0 {
7033        return Ok(None);
7034    }
7035    let oid = output.stdout.trim();
7036    if oid.is_empty() {
7037        return Ok(None);
7038    }
7039    Ok(Some(oid.to_string()))
7040}
7041
7042fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
7043    commit_has_shared_ref_except(cwd, oid, None)
7044}
7045
7046fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
7047    let contains = format!("--contains={oid}");
7048    let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
7049    Ok(shared.split(|byte| *byte == b'\n').any(|record| {
7050        !record.is_empty()
7051            && !record.starts_with(b"refs/worktree/")
7052            && !record.starts_with(b"refs/bisect/")
7053            && !record.starts_with(b"refs/rewritten/")
7054            && exclude.is_none_or(|excluded| record != excluded.as_bytes())
7055    }))
7056}
7057
7058/// Whether removing the worktree would take away an untracked file somebody
7059/// might want back.
7060///
7061/// Ordinary untracked files always count. So does an ignored file outside the
7062/// known build and cache directories, because an ignored path is only a path
7063/// Git was told not to track, which is where a local `.env` lives as readily as
7064/// compiler output.
7065///
7066/// Recognized build and cache output does not. A managed commit already leaves
7067/// it out rather than treating it as work, and the command that wrote it writes
7068/// it again. Counting it kept every worktree whose tests or build had run,
7069/// which is nearly all of them, so a merged pull request still left its
7070/// checkout behind. A repository nested in that output is somebody else's
7071/// history and counts whatever it sits under.
7072fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
7073    let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
7074    for raw in &ordinary {
7075        let (path, _) = untracked_record(raw, "untracked")?;
7076        if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
7077            continue;
7078        }
7079        return Ok(true);
7080    }
7081    let listed = untracked_listing(
7082        cwd,
7083        &[
7084            "ls-files",
7085            "--others",
7086            "--ignored",
7087            "--exclude-standard",
7088            "-z",
7089        ],
7090    )?;
7091    for raw in listed {
7092        let (path, nested) = untracked_record(&raw, "ignored")?;
7093        // A repository cloned into the output keeps the checkout whatever the
7094        // mode: its objects exist nowhere else, which is the one thing a
7095        // rebuild from origin cannot replace.
7096        if nested {
7097            return Ok(true);
7098        }
7099        if disposable == Disposable::BuildOutput && !is_generated_artifact(&path) {
7100            return Ok(true);
7101        }
7102    }
7103    Ok(false)
7104}
7105
7106fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
7107    let listed = run_git_bytes(cwd, args)?;
7108    if !listed.is_empty() && !listed.ends_with(&[0]) {
7109        bail!(
7110            "git returned an unterminated untracked-file list for {}",
7111            cwd.display()
7112        );
7113    }
7114    Ok(listed
7115        .split(|byte| *byte == 0)
7116        .filter(|raw| !raw.is_empty())
7117        .map(|raw| raw.to_vec())
7118        .collect())
7119}
7120
7121fn repository_has_recoverable_work_inner(
7122    cwd: &Path,
7123    include_ignored: bool,
7124    disposable: Disposable,
7125    visited: &mut BTreeSet<PathBuf>,
7126) -> Result<bool> {
7127    let canonical = std::fs::canonicalize(cwd)
7128        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7129    if !visited.insert(canonical.clone()) {
7130        bail!("submodule recursion revisited {}", canonical.display());
7131    }
7132    if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
7133        return Ok(true);
7134    }
7135    if !unsafe_index_flags(cwd)?.is_empty() {
7136        return Ok(true);
7137    }
7138    if attributes_may_be_modified(cwd)? {
7139        return Ok(true);
7140    }
7141    if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
7142        return Ok(true);
7143    }
7144    if include_ignored {
7145        let index = index_entries(cwd)?
7146            .into_iter()
7147            .map(|entry| (entry.path, (entry.mode, entry.oid)))
7148            .collect::<BTreeMap<_, _>>();
7149        let head = tree_entries(cwd, "HEAD")?
7150            .into_iter()
7151            .map(|entry| (entry.path, (entry.mode, entry.oid)))
7152            .collect::<BTreeMap<_, _>>();
7153        if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
7154            return Ok(true);
7155        }
7156        let tracked = tracked_entries(cwd)?;
7157        let effective = check_attributes(cwd, tracked.keys().cloned())?;
7158        let config = CheckoutConfig::read(cwd)?;
7159        for (path, entry) in tracked {
7160            let Some(worktree) = entry.worktree else {
7161                return Ok(true);
7162            };
7163            let attributes = effective.get(&path).ok_or_else(|| {
7164                spar_err!("git omitted attributes for {}", cwd.join(&path).display())
7165            })?;
7166            if path_has_ambiguous_transform(&config, attributes)? {
7167                return Ok(true);
7168            }
7169            let symlink_file = entry.index_mode == "120000"
7170                && worktree.mode == "100644"
7171                && worktree.raw_oid == entry.index_oid
7172                && config.symlinks == Some(false);
7173            if worktree.mode != entry.index_mode && !symlink_file {
7174                return Ok(true);
7175            }
7176            if entry.index_mode == "120000" {
7177                if worktree.raw_oid != entry.index_oid {
7178                    return Ok(true);
7179                }
7180                continue;
7181            }
7182            #[cfg(unix)]
7183            {
7184                let expected = if entry.index_mode == "100755" {
7185                    0o755
7186                } else {
7187                    0o644
7188                };
7189                if worktree.permissions != expected {
7190                    return Ok(true);
7191                }
7192            }
7193            if allows_expected_crlf(&config, attributes)? {
7194                let (normalized, every_lf_was_crlf) =
7195                    normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
7196                if !every_lf_was_crlf || normalized != entry.index_oid {
7197                    return Ok(true);
7198                }
7199            } else if worktree.raw_oid != entry.index_oid {
7200                return Ok(true);
7201            }
7202        }
7203    } else {
7204        let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
7205        if !run_git_bytes(cwd, &args)?.is_empty() {
7206            return Ok(true);
7207        }
7208    }
7209    for link in gitlinks(cwd)? {
7210        let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
7211            continue;
7212        };
7213        // Git stores a linked worktree's initialized submodule objects under
7214        // that worktree's administrative directory. Ordinary removal cannot
7215        // prove a local submodule commit exists anywhere else, even when both
7216        // working trees look clean.
7217        if include_ignored {
7218            return Ok(true);
7219        }
7220        let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7221        if head.trim() != link.oid {
7222            return Ok(true);
7223        }
7224        if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
7225        {
7226            return Ok(true);
7227        }
7228    }
7229    Ok(false)
7230}
7231
7232pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
7233    repository_has_recoverable_work(cwd, false)
7234}
7235
7236fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
7237    let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
7238    Ok(!run_git_bytes(cwd, &args)?.is_empty())
7239}
7240
7241#[cfg(unix)]
7242fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
7243    use std::os::unix::ffi::OsStringExt;
7244    Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
7245}
7246
7247#[cfg(not(unix))]
7248fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
7249    String::from_utf8(raw.to_vec())
7250        .map(PathBuf::from)
7251        .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
7252}
7253
7254/// Fingerprint the directory of a nested repository without reading inside it.
7255///
7256/// The files under it are that repository's, not this one's. They are recorded
7257/// against its own baseline whenever SPAR works there, and a run of its own may
7258/// legitimately add or remove entries while this call is in flight, so the
7259/// volatile directory fields stay out of the fingerprint. Identity and type
7260/// remain, which is what makes deleting the checkout, or replacing it with a
7261/// file, observable from the outer worktree.
7262/// What a path Git listed looks like once it is no longer there.
7263///
7264/// A reviewer building the project deletes and rewrites its output constantly,
7265/// so a path can go between the listing that named it and the stat that
7266/// measures it. Treating that as an unreadable worktree ended runs that had
7267/// nothing wrong with them. The record stays in the snapshot rather than being
7268/// dropped, so a path that vanishes cannot pass for one that was never there.
7269fn vanished_untracked_file() -> UntrackedFile {
7270    UntrackedFile {
7271        kind: 0,
7272        len: 0,
7273        modified: None,
7274        created: None,
7275        readonly: false,
7276        symlink_target: None,
7277        #[cfg(unix)]
7278        device: 0,
7279        #[cfg(unix)]
7280        inode: 0,
7281        #[cfg(unix)]
7282        mode: 0,
7283        #[cfg(unix)]
7284        change_seconds: 0,
7285        #[cfg(unix)]
7286        change_nanoseconds: 0,
7287    }
7288}
7289
7290fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
7291    let metadata = match std::fs::symlink_metadata(path) {
7292        Ok(metadata) => metadata,
7293        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7294        Err(e) => {
7295            return Err(spar_err!(
7296                "could not inspect the nested repository at {}: {e}",
7297                path.display()
7298            ))
7299        }
7300    };
7301    if !metadata.is_dir() {
7302        bail!(
7303            "git reported {} as a nested repository, but it is not a directory",
7304            path.display()
7305        );
7306    }
7307    if std::fs::symlink_metadata(path.join(".git")).is_err() {
7308        bail!(
7309            "git reported {} as a nested repository, but it has no Git entry",
7310            path.display()
7311        );
7312    }
7313    #[cfg(unix)]
7314    {
7315        use std::os::unix::fs::MetadataExt;
7316        Ok(UntrackedFile {
7317            kind: 3,
7318            len: 0,
7319            modified: None,
7320            created: metadata.created().ok(),
7321            readonly: metadata.permissions().readonly(),
7322            symlink_target: None,
7323            device: metadata.dev(),
7324            inode: metadata.ino(),
7325            mode: metadata.mode(),
7326            change_seconds: 0,
7327            change_nanoseconds: 0,
7328        })
7329    }
7330    #[cfg(not(unix))]
7331    {
7332        Ok(UntrackedFile {
7333            kind: 3,
7334            len: 0,
7335            modified: None,
7336            created: metadata.created().ok(),
7337            readonly: metadata.permissions().readonly(),
7338            symlink_target: None,
7339        })
7340    }
7341}
7342
7343fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
7344    let metadata = match std::fs::symlink_metadata(path) {
7345        Ok(metadata) => metadata,
7346        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7347        Err(e) => {
7348            return Err(spar_err!(
7349                "could not inspect untracked file {}: {e}",
7350                path.display()
7351            ))
7352        }
7353    };
7354    let kind = if metadata.file_type().is_symlink() {
7355        2
7356    } else if metadata.is_file() {
7357        1
7358    } else {
7359        bail!(
7360            "untracked path {} is not a regular file or symlink",
7361            path.display()
7362        );
7363    };
7364    let symlink_target = if kind == 2 {
7365        match std::fs::read_link(path) {
7366            Ok(target) => Some(os_str_bytes(target.as_os_str())?),
7367            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7368                return Ok(vanished_untracked_file())
7369            }
7370            Err(e) => {
7371                return Err(spar_err!(
7372                    "could not read untracked symlink {}: {e}",
7373                    path.display()
7374                ))
7375            }
7376        }
7377    } else {
7378        None
7379    };
7380    #[cfg(unix)]
7381    {
7382        use std::os::unix::fs::MetadataExt;
7383        Ok(UntrackedFile {
7384            kind,
7385            len: metadata.len(),
7386            modified: metadata.modified().ok(),
7387            created: metadata.created().ok(),
7388            readonly: metadata.permissions().readonly(),
7389            symlink_target,
7390            device: metadata.dev(),
7391            inode: metadata.ino(),
7392            mode: metadata.mode(),
7393            change_seconds: metadata.ctime(),
7394            change_nanoseconds: metadata.ctime_nsec(),
7395        })
7396    }
7397    #[cfg(not(unix))]
7398    {
7399        Ok(UntrackedFile {
7400            kind,
7401            len: metadata.len(),
7402            modified: metadata.modified().ok(),
7403            created: metadata.created().ok(),
7404            readonly: metadata.permissions().readonly(),
7405            symlink_target,
7406        })
7407    }
7408}
7409
7410#[cfg(unix)]
7411fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7412    use std::os::unix::ffi::OsStrExt;
7413    Ok(value.as_bytes().to_vec())
7414}
7415
7416#[cfg(not(unix))]
7417fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7418    value
7419        .to_str()
7420        .map(|value| value.as_bytes().to_vec())
7421        .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
7422}
7423
7424#[cfg(unix)]
7425fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7426    use std::os::unix::fs::MetadataExt;
7427    right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
7428}
7429
7430#[cfg(not(unix))]
7431fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7432    right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
7433}
7434
7435#[derive(Debug, Clone, serde::Serialize, Deserialize)]
7436pub struct BranchRecord {
7437    pub kind: String,
7438    pub number: i64,
7439}
7440
7441/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
7442/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
7443pub fn review_ref(number: i64) -> String {
7444    format!("refs/spar/pr-{number}")
7445}
7446
7447pub fn is_finished(state: &str) -> bool {
7448    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
7449}
7450
7451/// Write text through a temporary file and rename, so a kill cannot leave a
7452/// truncated file behind.
7453///
7454/// The follow-up queue is the one file spar rewrites in place rather than
7455/// appends to, and a truncated queue is lost work: what it held was never
7456/// written anywhere else.
7457pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
7458    if let Some(parent) = path.parent() {
7459        std::fs::create_dir_all(parent)
7460            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
7461    }
7462    // The extension defaults to `json` so `clear_state`, which removes a
7463    // leftover `pr-N.json.tmp` by name, keeps finding the one this wrote.
7464    let tmp = path.with_extension(format!(
7465        "{}.tmp",
7466        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
7467    ));
7468    std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
7469    std::fs::rename(&tmp, path)
7470        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
7471    Ok(())
7472}
7473
7474/// Whether a follow-up file already has an entry under this exact title.
7475///
7476/// Whole heading lines, case insensitively. A substring check answered yes
7477/// for "Retry loop" against "## Retry loop never terminates", which is a
7478/// different defect, and the new one was dropped with a line saying it was
7479/// already noted.
7480pub fn has_heading(text: &str, title: &str) -> bool {
7481    let wanted = title.trim().to_lowercase();
7482    if wanted.is_empty() {
7483        return false;
7484    }
7485    text.lines()
7486        .filter_map(|line| line.trim().strip_prefix("## "))
7487        .any(|heading| heading.trim().to_lowercase() == wanted)
7488}
7489
7490/// Write JSON through a temporary file and rename, so a kill cannot leave a
7491/// truncated state file behind.
7492pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
7493    write_text_atomic(path, &serde_json::to_string_pretty(value)?)
7494}
7495
7496/// One row of `gh pr list --json number,url,title,baseRefName,closingIssuesReferences`.
7497#[derive(Deserialize)]
7498#[serde(rename_all = "camelCase")]
7499pub struct LinkedPrRow {
7500    number: i64,
7501    #[serde(default)]
7502    url: String,
7503    #[serde(default)]
7504    title: String,
7505    #[serde(default)]
7506    closing_issues_references: Vec<IssueRef>,
7507}
7508
7509/// The listed pull requests, or `None` when the payload could not be read.
7510///
7511/// Separated from the gh call so the real payload shape can be tested, and kept
7512/// distinct from an empty list, because silently failing to parse would look
7513/// exactly like "no pull request exists", which is the answer that makes spar
7514/// implement over the top of somebody's work.
7515pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
7516    serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
7517}
7518
7519/// Among the listed pull requests, the first that would close `issue`.
7520pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
7521    rows.iter()
7522        .find(|row| {
7523            row.closing_issues_references
7524                .iter()
7525                .any(|linked| linked.number == issue)
7526        })
7527        .map(|row| PrRef {
7528            number: row.number,
7529            url: row.url.clone(),
7530            title: row.title.clone(),
7531        })
7532}
7533
7534/// Both halves together, for the tests that hold the real payload.
7535pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
7536    pick_linked_pr(&linked_pr_rows(json)?, issue)
7537}
7538
7539/// Flatten whatever `gh api --paginate` printed into a list of comments.
7540///
7541/// Current gh merges array pages into one array. Older builds concatenated one
7542/// document per page. A streaming parser reads either, and unlike splitting the
7543/// text on a bracket pair it cannot be fooled by a comment body that happens to
7544/// contain one, which would otherwise make a resume silently start over.
7545fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
7546    if text.trim().is_empty() {
7547        return Err(spar_err!("GitHub returned no comment data"));
7548    }
7549    let mut out = Vec::new();
7550    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7551        match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
7552            Value::Array(items) => out.extend(items),
7553            _ => return Err(spar_err!("unexpected non-array comment page")),
7554        }
7555    }
7556    Ok(out)
7557}
7558
7559pub fn parse_comment_pages(text: &str) -> Vec<Value> {
7560    let mut out = Vec::new();
7561    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7562        match value {
7563            Ok(Value::Array(items)) => out.extend(items),
7564            Ok(other) => out.push(other),
7565            Err(_) => break,
7566        }
7567    }
7568    out
7569}
7570
7571/// Whether a comment is a state block spar itself wrote.
7572///
7573/// The body has to open with the marker, so a person quoting it in a sentence
7574/// is not one; it has to parse, so a half-quoted block is not one; and it has
7575/// to be the viewer's own comment, the way `comments.rs` already tells spar's
7576/// writes apart. Anything less and `write_pr_state` patches somebody's comment
7577/// with JSON, and `spar clean --pr-state` later deletes it.
7578pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
7579    body.trim_start().starts_with(STATE_MARKER)
7580        && !viewer.trim().is_empty()
7581        && author.trim().eq_ignore_ascii_case(viewer.trim())
7582        && parse_state_comment(body).is_some()
7583}
7584
7585/// Extract the payload from a state comment. The marker is followed by JSON and
7586/// terminated with `-->`.
7587pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
7588    let marker = body.find(STATE_MARKER)?;
7589    let start = body[marker..].find('{')? + marker;
7590    let end = body.rfind('}')?;
7591    if end <= start {
7592        return None;
7593    }
7594    match serde_json::from_str(&body[start..=end]) {
7595        Ok(state) => Some(state),
7596        Err(_) => {
7597            logdim!("found a spar state comment but could not parse it");
7598            None
7599        }
7600    }
7601}
7602
7603fn choose_state_for_head(
7604    candidates: Vec<PersistedState>,
7605    actual_head: &str,
7606) -> Option<PersistedState> {
7607    let matching: Vec<PersistedState> = candidates
7608        .iter()
7609        .filter(|state| state.pr_head == actual_head)
7610        .cloned()
7611        .collect();
7612    if !matching.is_empty() {
7613        return newest_state(matching);
7614    }
7615    newest_state(candidates)
7616}
7617
7618fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
7619    candidates.into_iter().reduce(|best, candidate| {
7620        if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
7621            candidate
7622        } else {
7623            // The local candidate is supplied first. Keeping the first exact
7624            // tie recovers correctly from a local write followed by a failed
7625            // pull request state update, including legacy states with no
7626            // checkpoint field.
7627            best
7628        }
7629    })
7630}
7631
7632/// Where this binary lives, so `git filter-branch` can call back into it.
7633///
7634/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
7635/// tests, whose `current_exe` is the test harness rather than spar, and for
7636/// anyone who ships spar behind a wrapper script.
7637pub fn self_binary() -> Result<PathBuf> {
7638    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
7639        let path = PathBuf::from(path);
7640        if proc::is_executable(&path) {
7641            return Ok(path);
7642        }
7643        bail!(
7644            "SPAR_SELF_BIN is set to {}, which is not executable",
7645            path.display()
7646        );
7647    }
7648    std::env::current_exe()
7649        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
7650}
7651
7652fn bool_env(value: bool) -> &'static str {
7653    if value {
7654        "1"
7655    } else {
7656        "0"
7657    }
7658}
7659
7660/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
7661/// shell command, and an install path with a space in it is not exotic.
7662pub fn sh_quote(text: &str) -> String {
7663    format!("'{}'", text.replace('\'', r"'\''"))
7664}
7665
7666/// Style rules for the `scrub-filter` subcommand, which runs in a child process
7667/// spawned by git and so cannot see the parent's config.
7668pub fn style_from_env() -> Style {
7669    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
7670    Style {
7671        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
7672        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
7673        ..Style::permissive()
7674    }
7675}
7676
7677#[cfg(test)]
7678mod tests {
7679    use super::*;
7680    use crate::config::StateStore;
7681    use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
7682    use std::process::Command;
7683
7684    fn repo_for_titles() -> Repo {
7685        Repo {
7686            root: PathBuf::from("/nonexistent"),
7687            style: Style::default(),
7688            branch_prefix: String::new(),
7689            state_store: StateStore::Local,
7690            followups: crate::config::Followups::Issues,
7691            drafts: Drafts::Never,
7692            viewer: OnceLock::new(),
7693            checkpoints: Mutex::new(BTreeMap::new()),
7694            writes: WriteStats::default(),
7695        }
7696    }
7697
7698    #[test]
7699    fn write_results_accumulate_for_the_run() {
7700        let repo = repo_for_titles();
7701
7702        let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
7703        let _: std::result::Result<(), ()> = repo.record_write(Err(()));
7704
7705        assert_eq!(
7706            WriteSummary {
7707                attempted: 2,
7708                failed: 1,
7709            },
7710            repo.write_summary()
7711        );
7712    }
7713
7714    #[test]
7715    fn only_failed_write_preflights_join_the_summary() {
7716        let repo = repo_for_titles();
7717
7718        let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
7719        let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
7720
7721        assert_eq!(
7722            WriteSummary {
7723                attempted: 1,
7724                failed: 1,
7725            },
7726            repo.write_summary()
7727        );
7728    }
7729
7730    #[test]
7731    fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
7732        let repo = repo_for_titles();
7733
7734        assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
7735        assert_eq!(
7736            WriteSummary {
7737                attempted: 1,
7738                failed: 1,
7739            },
7740            repo.write_summary()
7741        );
7742    }
7743
7744    #[test]
7745    fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
7746        let mut repo = repo_for_titles();
7747        repo.followups = Followups::Local;
7748
7749        assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
7750        assert_eq!(WriteSummary::default(), repo.write_summary());
7751    }
7752
7753    #[test]
7754    fn a_failed_remote_state_read_stops_before_state_mutation() {
7755        let root = std::env::temp_dir().join(format!(
7756            "spar-state-preflight-{}-{}",
7757            std::process::id(),
7758            std::time::SystemTime::now()
7759                .duration_since(std::time::UNIX_EPOCH)
7760                .unwrap()
7761                .as_nanos()
7762        ));
7763        std::fs::create_dir_all(&root).unwrap();
7764        let _fixture = ReviewFixture { root: root.clone() };
7765        let mut repo = repo_for_titles();
7766        repo.root = root;
7767        repo.state_store = StateStore::Both;
7768        let state = PersistedState {
7769            version: 1,
7770            checkpoint: 4,
7771            round: 2,
7772            next_actor: "a".into(),
7773            status: Status::Pending,
7774            pr_head: "abc123".into(),
7775            ledger: Ledger::new(),
7776            filed: Vec::new(),
7777            open_findings: Vec::new(),
7778            disputes: Vec::new(),
7779            noted: Vec::new(),
7780        };
7781
7782        let error = repo
7783            .write_state_after_remote_read(
7784                7,
7785                &state,
7786                Err(crate::error::SparError::new("state comments unavailable")),
7787            )
7788            .unwrap_err();
7789
7790        assert!(error.to_string().contains("state comments unavailable"));
7791        assert!(!repo.state_path(7).exists());
7792        assert_eq!(0, repo.remembered_checkpoint(7));
7793        assert_eq!(
7794            WriteSummary {
7795                attempted: 1,
7796                failed: 1,
7797            },
7798            repo.write_summary()
7799        );
7800    }
7801
7802    #[test]
7803    fn only_known_build_and_cache_directories_are_generated_artifacts() {
7804        assert!(is_generated_artifact(Path::new("target/debug/artifact")));
7805        assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
7806        assert!(is_generated_artifact(Path::new(
7807            "package/node_modules/dependency/file.js"
7808        )));
7809        assert!(!is_generated_artifact(Path::new(
7810            "distribution/required-package.js"
7811        )));
7812        assert!(!is_generated_artifact(Path::new(
7813            "generated/required-fixture.txt"
7814        )));
7815        assert!(!is_generated_artifact(Path::new("local.env")));
7816    }
7817
7818    struct ReviewFixture {
7819        root: PathBuf,
7820    }
7821
7822    impl Drop for ReviewFixture {
7823        fn drop(&mut self) {
7824            let _ = std::fs::remove_dir_all(&self.root);
7825        }
7826    }
7827
7828    fn test_git(cwd: &Path, args: &[&str]) -> String {
7829        let output = Command::new("git")
7830            .args(args)
7831            .current_dir(cwd)
7832            .output()
7833            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
7834        assert!(
7835            output.status.success(),
7836            "git {args:?} failed: {}",
7837            String::from_utf8_lossy(&output.stderr)
7838        );
7839        String::from_utf8_lossy(&output.stdout).into_owned()
7840    }
7841
7842    fn review_fixture(
7843        tag: &str,
7844        number: i64,
7845    ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
7846        use std::sync::atomic::{AtomicU32, Ordering};
7847        static NEXT: AtomicU32 = AtomicU32::new(0);
7848        let id = NEXT.fetch_add(1, Ordering::Relaxed);
7849        let root =
7850            std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
7851        let origin = root.join("origin.git");
7852        let work = root.join("work");
7853        std::fs::create_dir_all(&origin).unwrap();
7854        std::fs::create_dir_all(&work).unwrap();
7855        test_git(&origin, &["init", "--bare", "-b", "main"]);
7856        test_git(&work, &["init", "-b", "main"]);
7857        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
7858        test_git(&work, &["config", "user.name", "spar test"]);
7859        test_git(&work, &["config", "commit.gpgsign", "false"]);
7860        test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
7861        test_git(&work, &["config", "filter.drop.smudge", "cat"]);
7862        std::fs::write(work.join("README.md"), "seed\n").unwrap();
7863        std::fs::write(work.join("data.txt"), "old\n").unwrap();
7864        std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
7865        std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
7866        test_git(&work, &["add", "."]);
7867        test_git(&work, &["commit", "-m", "seed"]);
7868        test_git(
7869            &work,
7870            &["remote", "add", "origin", origin.to_str().unwrap()],
7871        );
7872        test_git(&work, &["push", "-u", "origin", "main"]);
7873        test_git(
7874            &work,
7875            &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
7876        );
7877        let cfg = crate::config::parse(
7878            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
7879        )
7880        .unwrap();
7881        let repo = Repo::open(&work, &cfg).unwrap();
7882        let path = repo.worktree_for_pr_head(number).unwrap();
7883        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7884        (ReviewFixture { root }, repo, path, checkpoint)
7885    }
7886
7887    #[test]
7888    fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
7889        let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
7890
7891        repo.release_review_worktree_checked(901, &checkpoint)
7892            .unwrap();
7893
7894        assert!(!path.exists());
7895    }
7896
7897    #[test]
7898    fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
7899        let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
7900        let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
7901        std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
7902        test_git(&path, &["add", "recovery.txt"]);
7903        test_git(&path, &["commit", "-m", "recovery commit"]);
7904        let recovery = test_git(&path, &["rev-parse", "HEAD"]);
7905        test_git(&path, &["reset", "--hard", "main"]);
7906
7907        assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
7908        test_git(
7909            &path,
7910            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7911        );
7912    }
7913
7914    #[test]
7915    fn a_review_ref_reflog_only_commit_prevents_deletion() {
7916        let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
7917        let local_ref = review_ref(921);
7918        let original = test_git(&path, &["rev-parse", &local_ref]);
7919        let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
7920        let recovery = test_git(
7921            &path,
7922            &[
7923                "commit-tree",
7924                tree.trim(),
7925                "-p",
7926                original.trim(),
7927                "-m",
7928                "review ref recovery",
7929            ],
7930        );
7931        test_git(
7932            &path,
7933            &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
7934        );
7935        test_git(
7936            &path,
7937            &["update-ref", &local_ref, original.trim(), recovery.trim()],
7938        );
7939
7940        assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
7941        assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
7942        test_git(
7943            &path,
7944            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7945        );
7946    }
7947
7948    #[test]
7949    fn an_unpublished_commit_message_draft_is_recoverable() {
7950        let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
7951        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
7952        let git_dir = if raw.is_absolute() {
7953            raw
7954        } else {
7955            path.join(raw)
7956        };
7957        std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
7958
7959        assert!(repository_has_recoverable_work(&path, true).unwrap());
7960        assert_eq!(
7961            "unique recovery draft\n",
7962            std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
7963        );
7964    }
7965
7966    #[test]
7967    fn a_changed_review_worktree_is_retained_after_a_checked_read() {
7968        let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
7969        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
7970
7971        let error = repo
7972            .release_review_worktree_checked(902, &checkpoint)
7973            .unwrap_err();
7974
7975        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7976        assert!(error.to_string().contains("kept for recovery"), "{error}");
7977        assert_eq!(
7978            "recover me\n",
7979            std::fs::read_to_string(path.join("README.md")).unwrap()
7980        );
7981        repo.release_review_worktree(902);
7982    }
7983
7984    #[test]
7985    fn a_review_commit_is_retained_after_a_checked_read() {
7986        let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
7987        std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
7988        test_git(&path, &["add", "review-note.txt"]);
7989        test_git(&path, &["commit", "-m", "local review recovery"]);
7990        let head = test_git(&path, &["rev-parse", "HEAD"]);
7991
7992        let error = repo
7993            .release_review_worktree_checked(903, &checkpoint)
7994            .unwrap_err();
7995
7996        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7997        assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
7998        assert_eq!(
7999            "recover me\n",
8000            std::fs::read_to_string(path.join("review-note.txt")).unwrap()
8001        );
8002        repo.release_review_worktree(903);
8003    }
8004
8005    /// The read is no longer what refuses: a file the call created and Git was
8006    /// told to ignore is its own output. Removal still refuses, so the file is
8007    /// there to look at, which is the part that mattered.
8008    #[test]
8009    fn an_ignored_review_file_is_retained_after_a_checked_read() {
8010        let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
8011        std::fs::create_dir_all(path.join("generated")).unwrap();
8012        std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
8013
8014        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8015            .expect("its own output is not a change");
8016        let error = repo
8017            .release_review_worktree_checked(904, &checkpoint)
8018            .unwrap_err();
8019
8020        assert!(error.to_string().contains("recoverable"), "{error}");
8021        assert_eq!(
8022            "recover me\n",
8023            std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
8024        );
8025        repo.release_review_worktree(904);
8026    }
8027
8028    #[test]
8029    /// The checkpoint no longer refuses this: a path the project ignores is out
8030    /// of scope for a call that only read. What keeps the file is the gate that
8031    /// actually deletes the checkout, which refuses any ignored file outside a
8032    /// known build directory. That is the check with the stake in it, and it is
8033    /// the one that has to hold.
8034    fn a_preexisting_ignored_review_file_change_is_retained() {
8035        let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
8036        std::fs::create_dir_all(path.join("generated")).unwrap();
8037        let ignored = path.join("generated/recovery.txt");
8038        std::fs::write(&ignored, "before\n").unwrap();
8039        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8040        std::fs::write(&ignored, "after!\n").unwrap();
8041
8042        let error = repo
8043            .release_review_worktree_checked(905, &checkpoint)
8044            .unwrap_err();
8045
8046        assert!(error.to_string().contains("recoverable"), "{error}");
8047        assert!(path.exists());
8048        assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
8049        repo.release_review_worktree(905);
8050    }
8051
8052    #[test]
8053    fn a_preexisting_ignored_review_file_prevents_checked_removal() {
8054        let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
8055        std::fs::create_dir_all(path.join("generated")).unwrap();
8056        let ignored = path.join("generated/recovery.txt");
8057        std::fs::write(&ignored, "keep me\n").unwrap();
8058        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8059
8060        let error = repo
8061            .release_review_worktree_checked(906, &checkpoint)
8062            .unwrap_err();
8063
8064        assert!(error.to_string().contains("recoverable"), "{error}");
8065        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
8066    }
8067
8068    #[test]
8069    fn overwriting_a_preexisting_untracked_file_is_detected() {
8070        let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
8071        let untracked = path.join("notes.txt");
8072        std::fs::write(&untracked, "before\n").unwrap();
8073        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8074        std::fs::write(&untracked, "after!\n").unwrap();
8075
8076        let error = repo
8077            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8078            .unwrap_err();
8079
8080        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8081        assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
8082    }
8083
8084    #[test]
8085    fn an_assume_unchanged_edit_is_detected() {
8086        let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
8087        test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
8088        std::fs::write(path.join("README.md"), "hidden\n").unwrap();
8089
8090        let error = repo
8091            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8092            .unwrap_err();
8093
8094        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8095        assert_eq!(
8096            "hidden\n",
8097            std::fs::read_to_string(path.join("README.md")).unwrap()
8098        );
8099    }
8100
8101    #[test]
8102    fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
8103        let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
8104        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
8105        test_git(&path, &["add", "README.md"]);
8106        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8107
8108        let error = repo
8109            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8110            .unwrap_err();
8111
8112        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8113        assert_eq!(
8114            b"seed\r\n",
8115            std::fs::read(path.join("README.md")).unwrap().as_slice()
8116        );
8117    }
8118
8119    #[cfg(unix)]
8120    #[test]
8121    fn a_mode_edit_is_detected_when_filemode_is_disabled() {
8122        use std::os::unix::fs::PermissionsExt;
8123
8124        let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
8125        test_git(&path, &["config", "core.filemode", "false"]);
8126        let readme = path.join("README.md");
8127        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
8128        permissions.set_mode(0o755);
8129        std::fs::set_permissions(&readme, permissions).unwrap();
8130        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8131
8132        let error = repo
8133            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8134            .unwrap_err();
8135
8136        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8137        assert_eq!(
8138            0o755,
8139            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
8140        );
8141    }
8142
8143    #[test]
8144    fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
8145        let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
8146        std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
8147        test_git(&path, &["add", ".gitattributes"]);
8148        test_git(&path, &["commit", "-m", "select data filter"]);
8149        let baseline = repo.worktree_baseline(&path).unwrap();
8150        std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
8151
8152        assert!(repo
8153            .commit_pending_changes(&path, &baseline, "change data", "change data")
8154            .unwrap());
8155        let error = repo
8156            .refuse_unrepresented_tracked_changes(&path, &baseline)
8157            .unwrap_err();
8158
8159        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8160        assert_eq!(
8161            "secret: recover me\nnew\n",
8162            std::fs::read_to_string(path.join("data.txt")).unwrap()
8163        );
8164        assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
8165    }
8166
8167    #[test]
8168    fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
8169        let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
8170        std::fs::create_dir_all(path.join("target")).unwrap();
8171        let untracked = path.join("target/user.yaml");
8172        std::fs::write(&untracked, "user data\n").unwrap();
8173        let baseline = repo.worktree_baseline(&path).unwrap();
8174        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8175
8176        assert!(repo
8177            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8178            .unwrap());
8179
8180        assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
8181        assert_eq!(
8182            "?? target/user.yaml\n",
8183            test_git(&path, &["status", "--short", "--untracked-files=all"])
8184        );
8185        assert!(test_git(
8186            &path,
8187            &[
8188                "ls-tree",
8189                "-r",
8190                "--name-only",
8191                "HEAD",
8192                "--",
8193                "target/user.yaml"
8194            ]
8195        )
8196        .is_empty());
8197    }
8198
8199    #[test]
8200    fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
8201        let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
8202        std::fs::create_dir_all(path.join("target")).unwrap();
8203        let untracked = path.join("target/user.yaml");
8204        std::fs::write(&untracked, "before\n").unwrap();
8205        let baseline = repo.worktree_baseline(&path).unwrap();
8206        let before = test_git(&path, &["rev-parse", "HEAD"]);
8207        std::fs::write(&untracked, "after\n").unwrap();
8208        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8209
8210        let error = repo
8211            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8212            .unwrap_err();
8213
8214        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8215        assert!(error.to_string().contains("target/user.yaml"), "{error}");
8216        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
8217        assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
8218        assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
8219    }
8220
8221    #[test]
8222    fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
8223        let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
8224        let baseline = repo.worktree_baseline(&path).unwrap();
8225        std::fs::create_dir_all(path.join("target")).unwrap();
8226        std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
8227
8228        assert!(repo
8229            .commit_pending_changes(&path, &baseline, "add file", "add file")
8230            .unwrap());
8231
8232        assert_eq!(
8233            "new file\n",
8234            test_git(&path, &["show", "HEAD:target/new.txt"])
8235        );
8236        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8237    }
8238
8239    #[test]
8240    fn a_managed_commit_accepts_a_custom_output_rebuild() {
8241        let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
8242        exclude_paths(&repo, &["manager/public/"]);
8243        let output = path.join("manager/public/assets");
8244        std::fs::create_dir_all(&output).unwrap();
8245        std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
8246        std::fs::write(output.join("index.css"), "old styles\n").unwrap();
8247        let baseline = repo.worktree_baseline(&path).unwrap();
8248        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8249        std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
8250        std::fs::remove_file(output.join("old.js")).unwrap();
8251        std::fs::write(output.join("index.css"), "new styles\n").unwrap();
8252        std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
8253
8254        assert!(repo
8255            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8256            .unwrap());
8257        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8258        repo.refuse_changed_existing_untracked(&path, &baseline)
8259            .unwrap();
8260        repo.refuse_unrepresented_tracked_changes(&path, &baseline)
8261            .unwrap();
8262
8263        assert_eq!(
8264            "tracked change\n",
8265            test_git(&path, &["show", "HEAD:README.md"])
8266        );
8267        assert_eq!(
8268            "new source\n",
8269            test_git(&path, &["show", "HEAD:new-source.txt"])
8270        );
8271        assert!(test_git(
8272            &path,
8273            &[
8274                "ls-tree",
8275                "-r",
8276                "--name-only",
8277                "HEAD",
8278                "--",
8279                "manager/public"
8280            ]
8281        )
8282        .is_empty());
8283        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8284        assert!(!output.join("old.js").exists());
8285        assert_eq!(
8286            "new styles\n",
8287            std::fs::read_to_string(output.join("index.css")).unwrap()
8288        );
8289        assert!(repository_has_recoverable_work(&path, true).unwrap());
8290        repo.release_review_worktree(912);
8291        assert!(output.join("index-DUym-Rfj.js").exists());
8292    }
8293
8294    #[test]
8295    fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
8296        let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
8297        std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
8298        let baseline = repo.worktree_baseline(&path).unwrap();
8299        std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
8300        std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
8301
8302        let error = repo
8303            .commit_pending_changes(&path, &baseline, "change", "change")
8304            .unwrap_err();
8305
8306        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8307        assert!(error.to_string().contains("notes.txt"), "{error}");
8308    }
8309
8310    #[test]
8311    fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
8312        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
8313        let baseline = repo.worktree_baseline(&path).unwrap();
8314        let nested = path.join("generated/repro");
8315        std::fs::create_dir_all(&nested).unwrap();
8316        test_git(&nested, &["init"]);
8317        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8318
8319        assert!(repo
8320            .commit_pending_changes(&path, &baseline, "change", "change")
8321            .unwrap());
8322        assert!(!repo.has_uncommitted_changes(&path).unwrap());
8323        assert!(repository_has_recoverable_work(&path, true).unwrap());
8324        repo.release_review_worktree(927);
8325        assert!(nested.join(".git").exists());
8326    }
8327
8328    #[test]
8329    fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
8330        let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
8331        test_git(&path, &["config", "core.autocrlf", "true"]);
8332        std::fs::write(path.join("README.md"), "seed\n").unwrap();
8333        assert_eq!(
8334            test_git(&path, &["hash-object", "README.md"]).trim(),
8335            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8336        );
8337
8338        assert!(repository_has_recoverable_work(&path, true).unwrap());
8339        assert_eq!(
8340            "seed\n",
8341            std::fs::read_to_string(path.join("README.md")).unwrap()
8342        );
8343    }
8344
8345    #[test]
8346    fn autocrlf_input_overrides_a_crlf_core_eol() {
8347        let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
8348        test_git(&path, &["config", "core.autocrlf", "input"]);
8349        test_git(&path, &["config", "core.eol", "crlf"]);
8350        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
8351        assert_eq!(
8352            test_git(&path, &["hash-object", "README.md"]).trim(),
8353            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8354        );
8355
8356        assert!(repository_has_recoverable_work(&path, true).unwrap());
8357        assert_eq!(
8358            b"seed\r\n",
8359            std::fs::read(path.join("README.md")).unwrap().as_slice()
8360        );
8361    }
8362
8363    #[cfg(unix)]
8364    #[test]
8365    fn a_non_executable_permission_change_is_recoverable() {
8366        use std::os::unix::fs::PermissionsExt;
8367
8368        let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
8369        let readme = path.join("README.md");
8370        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
8371        permissions.set_mode(0o600);
8372        std::fs::set_permissions(&readme, permissions).unwrap();
8373        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8374
8375        let error = repo
8376            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8377            .unwrap_err();
8378
8379        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8380        assert!(repository_has_recoverable_work(&path, true).unwrap());
8381        assert_eq!(
8382            0o600,
8383            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
8384        );
8385    }
8386
8387    #[cfg(unix)]
8388    #[test]
8389    fn a_managed_commit_skips_signing_and_hooks() {
8390        use std::os::unix::fs::PermissionsExt;
8391
8392        let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
8393        let common = common_git_dir(&path).unwrap();
8394        let hook = common.join("hooks/pre-commit");
8395        let marker = fixture.root.join("hook-ran");
8396        std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
8397        std::fs::write(
8398            &hook,
8399            format!(
8400                "#!/bin/sh\nprintf ran > {}\nexit 1\n",
8401                sh_quote(marker.to_str().unwrap())
8402            ),
8403        )
8404        .unwrap();
8405        let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
8406        permissions.set_mode(0o755);
8407        std::fs::set_permissions(&hook, permissions).unwrap();
8408        test_git(&path, &["config", "commit.gpgsign", "true"]);
8409        test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
8410        std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
8411        test_git(&path, &["add", "managed.txt"]);
8412
8413        repo.commit_staged_changes(&path, "record managed change")
8414            .unwrap();
8415
8416        assert!(!marker.exists());
8417        assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
8418    }
8419
8420    #[test]
8421    fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
8422        let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
8423        std::fs::write(
8424            path.join(".gitattributes"),
8425            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
8426        )
8427        .unwrap();
8428        test_git(&path, &["add", ".gitattributes"]);
8429        test_git(&path, &["commit", "-m", "select automatic text"]);
8430        test_git(&path, &["config", "core.autocrlf", "true"]);
8431
8432        assert!(repository_has_recoverable_work(&path, true).unwrap());
8433    }
8434
8435    #[test]
8436    fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
8437        let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
8438        std::fs::write(
8439            path.join(".gitattributes"),
8440            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
8441        )
8442        .unwrap();
8443        test_git(&path, &["add", ".gitattributes"]);
8444        test_git(&path, &["commit", "-m", "select ident expansion"]);
8445        std::fs::write(path.join("README.md"), "seed\n").unwrap();
8446
8447        assert!(repository_has_recoverable_work(&path, true).unwrap());
8448    }
8449
8450    /// Add ignore rules the way SPAR does, without a tracked change the
8451    /// worktree would then be kept for.
8452    /// The failure this whole rule was rewritten for: a reviewer ran the
8453    /// project's binding generator to check a finding, and the lockfile it left
8454    /// beside its own manifest matched no directory name spar knew.
8455    #[test]
8456    fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
8457        let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
8458        exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
8459        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8460        std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
8461        std::fs::write(
8462            path.join("bindings/uniffi-bindgen/Cargo.lock"),
8463            "# generated\n",
8464        )
8465        .unwrap();
8466
8467        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8468            .expect("a path the project ignores is the call's own output");
8469    }
8470
8471    #[test]
8472    fn an_attribute_file_under_build_output_is_not_a_filter_change() {
8473        let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
8474        exclude_paths(&repo, &["target/"]);
8475        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8476        std::fs::create_dir_all(path.join("target/package")).unwrap();
8477        std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
8478
8479        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8480            .expect("an ignored attribute file governs only ignored paths");
8481        assert!(
8482            !repo.has_uncommitted_changes(&path).unwrap(),
8483            "ignored dependency attributes must not stop the next review"
8484        );
8485    }
8486
8487    #[test]
8488    fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
8489        let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
8490        std::fs::create_dir_all(path.join("source")).unwrap();
8491        std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
8492
8493        assert!(repo.has_uncommitted_changes(&path).unwrap());
8494    }
8495
8496    #[test]
8497    fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
8498        for source in ["repository", "nested", "local", "global"] {
8499            let (_fixture, repo, path, _initial) = review_fixture(source, 945);
8500            std::fs::create_dir_all(path.join("manager")).unwrap();
8501            match source {
8502                "repository" => {
8503                    std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
8504                        .unwrap();
8505                    test_git(&path, &["add", ".gitignore"]);
8506                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
8507                }
8508                "nested" => {
8509                    std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
8510                    test_git(&path, &["add", "manager/.gitignore"]);
8511                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
8512                }
8513                "local" => exclude_paths(&repo, &["manager/scratch/"]),
8514                "global" => {
8515                    let excludes = repo.root().parent().unwrap().join("global-ignore");
8516                    std::fs::write(&excludes, "manager/scratch/\n").unwrap();
8517                    test_git(
8518                        &path,
8519                        &["config", "core.excludesFile", excludes.to_str().unwrap()],
8520                    );
8521                }
8522                _ => unreachable!(),
8523            }
8524            let output = path.join("manager/scratch");
8525            std::fs::create_dir_all(&output).unwrap();
8526            std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
8527            std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
8528            let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8529            std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
8530            std::fs::remove_file(output.join(".DS_Store")).unwrap();
8531            for file in [
8532                ".gitattributes",
8533                "Cargo.lock",
8534                "font.woff2",
8535                "bundle.js",
8536                "cache.db",
8537            ] {
8538                std::fs::write(output.join(file), "generated\n").unwrap();
8539            }
8540            #[cfg(unix)]
8541            std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
8542            let nested = output.join("dependency");
8543            std::fs::create_dir_all(&nested).unwrap();
8544            test_git(&nested, &["init"]);
8545            repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8546                .unwrap_or_else(|error| panic!("{source}: {error}"));
8547            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
8548
8549            let baseline = repo.worktree_baseline(&path).unwrap();
8550            std::fs::write(path.join("README.md"), "source fix\n").unwrap();
8551            std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
8552            std::fs::remove_file(output.join("Cargo.lock")).unwrap();
8553            std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
8554            std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
8555            assert!(repo
8556                .commit_pending_changes(&path, &baseline, "fix source", "fix source")
8557                .unwrap_or_else(|error| panic!("{source}: {error}")));
8558            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
8559            assert!(test_git(
8560                &path,
8561                &[
8562                    "ls-tree",
8563                    "-r",
8564                    "--name-only",
8565                    "HEAD",
8566                    "--",
8567                    "manager/scratch"
8568                ]
8569            )
8570            .is_empty());
8571            assert!(
8572                repository_has_recoverable_work(&path, true).unwrap(),
8573                "{source}"
8574            );
8575            repo.release_review_worktree(945);
8576            assert_eq!(
8577                "LOCAL=updated\n",
8578                std::fs::read_to_string(output.join(".env.local")).unwrap()
8579            );
8580            assert!(nested.join(".git").exists(), "{source}");
8581        }
8582    }
8583
8584    /// Two reviewers share one checkout. The marker written when the first
8585    /// one's answer was refused used to be a brand new untracked file in the
8586    /// snapshot the second one was compared against.
8587    #[test]
8588    fn a_recovery_marker_is_not_a_change_to_the_worktree() {
8589        let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
8590        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8591        let _ = uncertain_worktree_change(&path, "an earlier call was refused");
8592
8593        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8594            .expect("spar's own marker is not something a call did");
8595    }
8596
8597    #[test]
8598    fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
8599        let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
8600
8601        let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
8602            .expect("a path a build removed is not an unreadable worktree");
8603
8604        assert_eq!(vanished_untracked_file(), gone);
8605    }
8606
8607    /// A path that goes is still a path that was there. Dropping it from the
8608    /// snapshot would let it pass for one the call never touched.
8609    #[test]
8610    fn a_vanished_path_is_not_recorded_as_absent() {
8611        let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
8612        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
8613        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8614        std::fs::remove_file(path.join("notes.txt")).unwrap();
8615
8616        let error = repo
8617            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8618            .unwrap_err();
8619
8620        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8621    }
8622
8623    fn exclude_paths(repo: &Repo, lines: &[&str]) {
8624        use std::io::Write;
8625        let path = repo.root().join(".git").join("info").join("exclude");
8626        let mut file = std::fs::OpenOptions::new()
8627            .create(true)
8628            .append(true)
8629            .open(&path)
8630            .unwrap();
8631        for line in lines {
8632            writeln!(file, "{line}").unwrap();
8633        }
8634    }
8635
8636    #[test]
8637    fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
8638        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
8639        exclude_paths(&repo, &["dist/"]);
8640        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8641        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8642        let baseline = repo.worktree_baseline(&path).unwrap();
8643        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8644        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8645        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8646        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8647
8648        assert!(repo
8649            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8650            .unwrap());
8651        assert_eq!(
8652            "tracked change\n",
8653            std::fs::read_to_string(path.join("README.md")).unwrap()
8654        );
8655    }
8656
8657    #[test]
8658    fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
8659        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
8660        exclude_paths(&repo, &["dist/"]);
8661        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8662        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8663        std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
8664        let baseline = repo.worktree_baseline(&path).unwrap();
8665        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8666        std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
8667        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8668        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8669
8670        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8671        repo.refuse_changed_existing_untracked(&path, &baseline)
8672            .unwrap();
8673    }
8674
8675    #[test]
8676    fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
8677        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
8678        exclude_paths(&repo, &["dist/"]);
8679        std::fs::create_dir_all(path.join("dist")).unwrap();
8680        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8681        let baseline = repo.worktree_baseline(&path).unwrap();
8682        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8683        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8684
8685        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8686        repo.refuse_changed_existing_untracked(&path, &baseline)
8687            .unwrap();
8688    }
8689
8690    /// The run this comes from: a reviewer rebuilt a project whose build output
8691    /// lands in `manager/public/assets/`, which no directory list will ever
8692    /// name. The guard inside the call kept the review. This check then called
8693    /// the same thirteen files unrepresentable and ended the round, five
8694    /// minutes of review thrown away over a path Git had been told to disown.
8695    /// The run this comes from: `gh pr merge --delete-branch` could not delete
8696    /// `issue-784` because `.spar-worktrees/issue-784`, left by the run that
8697    /// opened the pull request, still had it checked out.
8698    #[test]
8699    fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
8700        let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
8701        let held = repo.root().join(WORKTREE_DIR).join("issue-950");
8702        std::fs::create_dir_all(held.parent().unwrap()).unwrap();
8703        test_git(
8704            repo.root(),
8705            &[
8706                "worktree",
8707                "add",
8708                "-b",
8709                "issue-950",
8710                held.to_str().unwrap(),
8711                "main",
8712            ],
8713        );
8714
8715        let found = repo
8716            .worktree_holding("issue-950")
8717            .expect("the branch is checked out somewhere");
8718
8719        assert!(found.ends_with("issue-950"), "{}", found.display());
8720        assert_eq!(None, repo.worktree_holding("issue-951"));
8721    }
8722
8723    #[test]
8724    fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
8725        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
8726        exclude_paths(&repo, &["manager/public/assets/"]);
8727        std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
8728        let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
8729        std::fs::write(&bundle, "first build\n").unwrap();
8730        let baseline = repo.worktree_baseline(&path).unwrap();
8731        std::fs::write(&bundle, "second build\n").unwrap();
8732        std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
8733
8734        repo.refuse_read_only_leavings(&path, &baseline).unwrap();
8735        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8736    }
8737
8738    #[test]
8739    fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
8740        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
8741        exclude_paths(&repo, &["dist/"]);
8742        let baseline = repo.worktree_baseline(&path).unwrap();
8743        std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
8744
8745        let error = repo
8746            .refuse_read_only_leavings(&path, &baseline)
8747            .unwrap_err();
8748
8749        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8750        assert!(error.to_string().contains("scratch.md"), "{error}");
8751    }
8752
8753    #[test]
8754    fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
8755        let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
8756        exclude_paths(&repo, &["dist/", ".env.local"]);
8757        std::fs::create_dir_all(path.join("dist")).unwrap();
8758        let baseline = repo.worktree_baseline(&path).unwrap();
8759        std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
8760        std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
8761
8762        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8763        assert!(repository_has_recoverable_work(&path, true).unwrap());
8764    }
8765
8766    #[test]
8767    fn a_read_only_inspection_may_rebuild_generated_output() {
8768        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
8769        exclude_paths(&repo, &["dist/"]);
8770        std::fs::create_dir_all(path.join("dist")).unwrap();
8771        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8772        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8773        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8774        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8775
8776        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8777            .unwrap();
8778    }
8779
8780    #[test]
8781    fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
8782        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
8783        exclude_paths(&repo, &["dist/", ".env.local"]);
8784        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
8785        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8786        std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
8787
8788        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8789            .unwrap();
8790    }
8791
8792    #[test]
8793    fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
8794        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
8795        exclude_paths(&repo, &["dist/", ".env.local"]);
8796        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
8797        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8798        std::fs::remove_file(path.join(".env.local")).unwrap();
8799
8800        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8801            .unwrap();
8802    }
8803
8804    #[test]
8805    fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
8806        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
8807        exclude_paths(&repo, &["dist/"]);
8808        std::fs::write(path.join("notes.md"), "before\n").unwrap();
8809        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8810        std::fs::write(path.join("notes.md"), "after\n").unwrap();
8811
8812        let error = repo
8813            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8814            .unwrap_err();
8815
8816        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8817        assert!(error.to_string().contains("notes.md"), "{error}");
8818    }
8819
8820    #[test]
8821    fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
8822        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
8823        exclude_paths(&repo, &["dist/", "local.env"]);
8824        std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
8825        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8826        // Stop ignoring it, so the same bytes are now an ordinary untracked
8827        // file. What a path is counts, not only whether it moved.
8828        std::fs::write(
8829            repo.root().join(".git").join("info").join("exclude"),
8830            "dist/\n",
8831        )
8832        .unwrap();
8833
8834        let error = repo
8835            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8836            .unwrap_err();
8837
8838        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8839        assert!(error.to_string().contains("local.env"), "{error}");
8840    }
8841
8842    #[test]
8843    fn a_read_only_inspection_names_the_tracked_file_that_moved() {
8844        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
8845        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8846        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
8847
8848        let error = repo
8849            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8850            .unwrap_err();
8851
8852        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8853        assert!(error.to_string().contains("README.md"), "{error}");
8854    }
8855
8856    #[test]
8857    fn build_output_alone_does_not_keep_a_worktree() {
8858        let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
8859        exclude_paths(&repo, &["target/", "dist/"]);
8860        std::fs::create_dir_all(path.join("target/debug")).unwrap();
8861        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8862        std::fs::create_dir_all(path.join("dist/cli")).unwrap();
8863        std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
8864
8865        assert!(!repository_has_recoverable_work(&path, true).unwrap());
8866        repo.release_review_worktree(933);
8867
8868        assert!(!path.exists());
8869    }
8870
8871    #[test]
8872    fn an_ignored_file_outside_build_output_keeps_a_worktree() {
8873        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
8874        exclude_paths(&repo, &["target/", ".env.local"]);
8875        std::fs::create_dir_all(path.join("target/debug")).unwrap();
8876        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8877        std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
8878
8879        assert!(repository_has_recoverable_work(&path, true).unwrap());
8880        repo.release_review_worktree(934);
8881
8882        assert_eq!(
8883            "TOKEN=keep me\n",
8884            std::fs::read_to_string(path.join(".env.local")).unwrap()
8885        );
8886    }
8887
8888    #[test]
8889    fn a_repository_nested_in_build_output_keeps_a_worktree() {
8890        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
8891        exclude_paths(&repo, &["node_modules/"]);
8892        let nested = path.join("node_modules/local-dep");
8893        std::fs::create_dir_all(&nested).unwrap();
8894        test_git(&nested, &["init"]);
8895        std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
8896
8897        assert!(repository_has_recoverable_work(&path, true).unwrap());
8898        repo.release_review_worktree(935);
8899
8900        assert!(nested.join(".git").exists());
8901    }
8902
8903    #[test]
8904    fn an_ordinary_untracked_file_keeps_a_worktree() {
8905        let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
8906        std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
8907
8908        assert!(repository_has_recoverable_work(&path, true).unwrap());
8909        repo.release_review_worktree(936);
8910
8911        assert_eq!(
8912            "somebody's notes\n",
8913            std::fs::read_to_string(path.join("notes.md")).unwrap()
8914        );
8915    }
8916
8917    #[test]
8918    fn a_legacy_crlf_checkout_is_retained_conservatively() {
8919        let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
8920        std::fs::write(
8921            path.join(".gitattributes"),
8922            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
8923        )
8924        .unwrap();
8925        test_git(&path, &["add", ".gitattributes"]);
8926        test_git(&path, &["commit", "-m", "select legacy line endings"]);
8927
8928        assert!(repository_has_recoverable_work(&path, true).unwrap());
8929    }
8930
8931    #[test]
8932    fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
8933        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
8934        let nested = path.join("tracked");
8935        std::fs::create_dir_all(&nested).unwrap();
8936        std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
8937        test_git(&path, &["add", "tracked/seed.txt"]);
8938        test_git(&path, &["commit", "-m", "add tracked directory"]);
8939        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8940        test_git(&nested, &["init"]);
8941
8942        let error = repo
8943            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8944            .unwrap_err();
8945
8946        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8947        assert!(error.to_string().contains("Git entry"), "{error}");
8948        assert!(nested.join(".git").exists());
8949    }
8950
8951    /// A reviewer that clones a reproduction into ignored build output used to
8952    /// lose the whole review. The state check refused every Git entry it had
8953    /// not put there, including the one Git reports as a nested checkout and
8954    /// the untracked snapshot already records by identity.
8955    #[test]
8956    fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
8957        let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
8958        exclude_paths(&repo, &["target/"]);
8959        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8960        let repro = path.join("target/pr918-repro");
8961        std::fs::create_dir_all(&repro).unwrap();
8962        test_git(&repro, &["init"]);
8963        std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
8964
8965        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8966            .unwrap();
8967
8968        assert!(repository_has_recoverable_work(&path, true).unwrap());
8969        assert!(repro.join(".git").exists());
8970    }
8971
8972    /// What sits under a nested checkout is that repository's business, so the
8973    /// scan stops at its directory rather than reading a checkout of its own
8974    /// as an entry the outer worktree has to account for.
8975    #[test]
8976    fn a_repository_inside_a_nested_checkout_is_left_alone() {
8977        let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
8978        exclude_paths(&repo, &["target/"]);
8979        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8980        let repro = path.join("target/repro");
8981        let vendored = repro.join("vendor/dep");
8982        std::fs::create_dir_all(&vendored).unwrap();
8983        test_git(&repro, &["init"]);
8984        test_git(&vendored, &["init"]);
8985
8986        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8987            .unwrap();
8988
8989        assert!(vendored.join(".git").exists());
8990    }
8991
8992    #[test]
8993    fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
8994        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
8995
8996        let state = ignored_untracked_state(repo.root()).unwrap();
8997
8998        let relative = path.strip_prefix(repo.root()).unwrap();
8999        assert!(
9000            state.files.contains_key(relative),
9001            "{:?}",
9002            state.files.keys().collect::<Vec<_>>()
9003        );
9004        assert!(state.is_ignored(relative));
9005    }
9006
9007    #[test]
9008    fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
9009        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
9010        let baseline = repo.worktree_baseline(repo.root()).unwrap();
9011        std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
9012        std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
9013
9014        repo.refuse_new_ignored_files(repo.root(), &baseline)
9015            .unwrap();
9016        repo.refuse_changed_existing_untracked(repo.root(), &baseline)
9017            .unwrap();
9018    }
9019
9020    #[test]
9021    fn deleting_a_resident_worktree_during_a_call_is_refused() {
9022        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
9023        let baseline = repo.worktree_baseline(repo.root()).unwrap();
9024        let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
9025        std::fs::remove_dir_all(&path).unwrap();
9026
9027        let read_error = repo
9028            .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
9029            .unwrap_err();
9030        assert!(
9031            read_error.to_string().contains("review-932"),
9032            "{read_error}"
9033        );
9034        let error = repo
9035            .refuse_new_ignored_files(repo.root(), &baseline)
9036            .unwrap_err();
9037
9038        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9039        assert!(error.to_string().contains("review-932"), "{error}");
9040    }
9041
9042    #[test]
9043    fn a_nested_repository_record_is_read_as_a_plain_path() {
9044        let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
9045        assert_eq!(Path::new("vendor/checkout"), path);
9046        assert!(nested);
9047
9048        let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
9049        assert_eq!(Path::new("vendor/notes.txt"), path);
9050        assert!(!nested);
9051
9052        assert!(untracked_record(b"/", "untracked").is_err());
9053    }
9054
9055    #[cfg(unix)]
9056    #[test]
9057    fn a_non_utf8_git_path_is_preserved_without_loss() {
9058        use std::os::unix::ffi::OsStrExt;
9059
9060        let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
9061
9062        assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
9063    }
9064
9065    #[test]
9066    fn guarded_merge_pins_the_reviewed_head() {
9067        let args = merge_pr_args("36", Some("abc123"), true);
9068        assert_eq!(
9069            vec![
9070                "pr",
9071                "merge",
9072                "36",
9073                "--squash",
9074                "--delete-branch",
9075                "--match-head-commit",
9076                "abc123"
9077            ],
9078            args
9079        );
9080    }
9081
9082    #[test]
9083    fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
9084        let pr = PrRef {
9085            number: 7,
9086            url: "https://example.test/pull/7".into(),
9087            title: "part one".into(),
9088        };
9089        let result = reconcile_pr_creation(
9090            "split-34-1",
9091            Err(crate::error::SparError::new("connection lost")),
9092            Ok(Some(pr)),
9093        )
9094        .unwrap();
9095        assert_eq!(7, result.number);
9096    }
9097
9098    #[test]
9099    fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
9100        let error = reconcile_pr_creation(
9101            "split-34-1",
9102            Err(crate::error::SparError::new("permission denied")),
9103            Ok(None),
9104        )
9105        .unwrap_err();
9106        assert!(error.to_string().contains("permission denied"), "{error}");
9107    }
9108
9109    #[test]
9110    fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
9111        let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
9112        assert!(pr_for_base(text, "split-34-2", "split-34-1")
9113            .unwrap()
9114            .is_none());
9115        let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
9116        assert_eq!(7, found.number);
9117    }
9118
9119    #[test]
9120    fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
9121        let result = reconcile_comment_post(
9122            34,
9123            "the summary",
9124            crate::error::SparError::new("connection lost"),
9125            Ok(vec![serde_json::json!({"body": "the summary"})]),
9126        );
9127        assert!(result.is_ok(), "{result:?}");
9128    }
9129
9130    #[test]
9131    fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
9132        let error = reconcile_comment_post(
9133            34,
9134            "the summary",
9135            crate::error::SparError::new("connection lost"),
9136            Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
9137        )
9138        .unwrap_err();
9139        assert_eq!("connection lost", error.to_string());
9140    }
9141
9142    #[test]
9143    fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
9144        let error = reconcile_comment_post(
9145            34,
9146            "the summary",
9147            crate::error::SparError::new("connection lost"),
9148            Err(crate::error::SparError::new("comments unavailable")),
9149        )
9150        .unwrap_err();
9151        assert!(
9152            error.to_string().contains("could not be verified"),
9153            "{error}"
9154        );
9155        assert!(
9156            error.to_string().contains("comments unavailable"),
9157            "{error}"
9158        );
9159        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9160        assert!(!error.worth_retrying());
9161    }
9162
9163    #[test]
9164    fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
9165        let result = reconcile_issue_edit(
9166            34,
9167            "wanted body",
9168            crate::error::SparError::new("connection lost"),
9169            Ok("wanted body".to_string()),
9170        );
9171        assert!(result.is_ok(), "{result:?}");
9172    }
9173
9174    #[test]
9175    fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
9176        let error = reconcile_issue_edit(
9177            34,
9178            "wanted body",
9179            crate::error::SparError::new("connection lost"),
9180            Err(crate::error::SparError::new("issue unavailable")),
9181        )
9182        .unwrap_err();
9183        assert!(
9184            error.to_string().contains("could not be verified"),
9185            "{error}"
9186        );
9187        assert!(error.to_string().contains("issue unavailable"), "{error}");
9188        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9189        assert!(!error.worth_retrying());
9190    }
9191
9192    #[test]
9193    fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
9194        let found = ExistingIssue {
9195            number: 101,
9196            url: "https://example.test/issues/101".into(),
9197            title: "child".into(),
9198            body: "body".into(),
9199            open: true,
9200        };
9201        let url = reconcile_issue_creation(
9202            "child",
9203            Err(crate::error::SparError::new("connection lost")),
9204            Ok(Some(found)),
9205        )
9206        .unwrap();
9207        assert_eq!("https://example.test/issues/101", url);
9208    }
9209
9210    #[test]
9211    fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
9212        let error = reconcile_issue_creation(
9213            "child",
9214            Err(crate::error::SparError::new("permission denied")),
9215            Ok(None),
9216        )
9217        .unwrap_err();
9218        assert!(error.to_string().contains("permission denied"), "{error}");
9219    }
9220
9221    #[test]
9222    fn an_unverifiable_issue_creation_is_marked_uncertain() {
9223        let error = reconcile_issue_creation(
9224            "child",
9225            Err(crate::error::SparError::new("connection lost")),
9226            Err(crate::error::SparError::new("issues unavailable")),
9227        )
9228        .unwrap_err();
9229        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9230        assert!(!error.worth_retrying());
9231    }
9232
9233    #[test]
9234    fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
9235        let result = reconcile_failed_split_push(
9236            "split-34-1",
9237            crate::error::SparError::new("connection lost"),
9238            Ok("abc123\n".into()),
9239            Ok("abc123\trefs/heads/split-34-1\n".into()),
9240        );
9241        assert!(result.is_ok(), "{result:?}");
9242    }
9243
9244    #[test]
9245    fn a_split_push_collision_is_definite_and_never_overwrites() {
9246        let error = reconcile_failed_split_push(
9247            "split-34-1",
9248            crate::error::SparError::new("lease rejected"),
9249            Ok("abc123\n".into()),
9250            Ok("def456\trefs/heads/split-34-1\n".into()),
9251        )
9252        .unwrap_err();
9253        assert!(!error.retain_worktree());
9254        assert!(
9255            error.to_string().contains("Nothing was overwritten"),
9256            "{error}"
9257        );
9258    }
9259
9260    #[test]
9261    fn an_unreadable_split_push_result_keeps_the_worktree() {
9262        let error = reconcile_failed_split_push(
9263            "split-34-1",
9264            crate::error::SparError::new("connection lost"),
9265            Ok("abc123\n".into()),
9266            Err(crate::error::SparError::new("origin unavailable")),
9267        )
9268        .unwrap_err();
9269        assert!(error.retain_worktree());
9270        assert!(error.to_string().contains("could not confirm"), "{error}");
9271    }
9272
9273    /// Follow-up deduplication compares a title it computed against the title
9274    /// GitHub stored. If those two transforms can disagree, the check never
9275    /// matches and every review round files another copy of the same issue.
9276    #[test]
9277    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
9278        let repo = repo_for_titles();
9279        for raw in [
9280            "Retry loop spins \u{2014} Retry-After parses to zero",
9281            "plain title",
9282            "  spread   over\nlines  ",
9283            "\u{1F916} Generated with something",
9284            &format!("a \u{2014} {}", "very long title ".repeat(20)),
9285            &"x".repeat(300),
9286            &format!("{} \u{2014} end", "y".repeat(88)),
9287            // Exactly the budget, with two spaceless dashes. The scrub turns
9288            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
9289            // character over budget per dash and a second pass clips again,
9290            // producing a different string. Scrub-then-clip cannot.
9291            &{
9292                let tail = "a\u{2014}b c\u{2014}d";
9293                let pad = Style::default().max_title_chars - tail.chars().count();
9294                format!("{}{tail}", "w".repeat(pad))
9295            },
9296        ] {
9297            let once = repo.clean_title(raw).unwrap();
9298            let twice = repo.clean_title(&once).unwrap();
9299            assert_eq!(once, twice, "not idempotent for {raw:?}");
9300            assert!(
9301                once.chars().count() <= repo.style.max_title_chars,
9302                "over budget: {once:?}"
9303            );
9304            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
9305        }
9306    }
9307
9308    #[test]
9309    fn a_title_with_an_em_dash_survives_as_readable_text() {
9310        let repo = repo_for_titles();
9311        assert_eq!(
9312            "Retry loop spins, Retry-After parses to zero",
9313            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
9314                .unwrap()
9315        );
9316    }
9317
9318    #[test]
9319    fn sh_quote_survives_a_quote() {
9320        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
9321    }
9322
9323    #[test]
9324    fn sh_quote_wraps_a_space() {
9325        assert_eq!(
9326            "'/Applications/My App/spar'",
9327            sh_quote("/Applications/My App/spar")
9328        );
9329    }
9330
9331    #[test]
9332    fn finished_states_are_recognised_case_insensitively() {
9333        assert!(is_finished("MERGED"));
9334        assert!(is_finished("closed"));
9335        assert!(!is_finished("OPEN"));
9336        assert!(!is_finished(""));
9337    }
9338
9339    fn state() -> PersistedState {
9340        PersistedState {
9341            version: 1,
9342            checkpoint: 0,
9343            round: 4,
9344            next_actor: "codex".into(),
9345            status: Status::Pending,
9346            pr_head: "abc123".into(),
9347            ledger: Ledger::new(),
9348            filed: vec![],
9349            open_findings: vec![Finding {
9350                severity: Severity::Blocking,
9351                title: "Unchecked error".into(),
9352                detail: "the failure is discarded".into(),
9353                file: "src/a.rs:12".into(),
9354                ..Finding::default()
9355            }],
9356            disputes: vec![Dispute {
9357                title: "Retry limit".into(),
9358                file: "src/net.rs".into(),
9359                reasoning: "the caller already bounds it".into(),
9360            }],
9361            noted: vec![Finding {
9362                severity: Severity::NonBlocking,
9363                title: "Timeout is fixed".into(),
9364                file: "src/config.rs".into(),
9365                ..Finding::default()
9366            }],
9367        }
9368    }
9369
9370    #[test]
9371    fn a_state_comment_round_trips() {
9372        let body = format!(
9373            "{STATE_MARKER}\n{}\n-->",
9374            serde_json::to_string(&state()).unwrap()
9375        );
9376        let back = parse_state_comment(&body).unwrap();
9377        assert_eq!(4, back.round);
9378        assert_eq!("codex", back.next_actor);
9379        assert_eq!("abc123", back.pr_head);
9380        assert_eq!("Unchecked error", back.open_findings[0].title);
9381        assert_eq!("src/net.rs", back.disputes[0].file);
9382        assert_eq!("Timeout is fixed", back.noted[0].title);
9383    }
9384
9385    #[test]
9386    fn old_state_without_new_lists_still_parses() {
9387        let body = format!(
9388            "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
9389             \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
9390        );
9391        let back = parse_state_comment(&body).expect("old state");
9392        assert!(back.open_findings.is_empty());
9393        assert!(back.disputes.is_empty());
9394        assert!(back.noted.is_empty());
9395        assert!(back.pr_head.is_empty());
9396        assert_eq!(0, back.checkpoint);
9397    }
9398
9399    #[test]
9400    fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
9401        let mut local = state();
9402        local.pr_head = "old".into();
9403        local.round = 9;
9404        let mut remote = state();
9405        remote.pr_head = "current".into();
9406        remote.round = 4;
9407
9408        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9409        assert_eq!("current", chosen.pr_head);
9410        assert_eq!(4, chosen.round);
9411    }
9412
9413    #[test]
9414    fn checkpoint_order_breaks_same_round_ties() {
9415        let mut local = state();
9416        local.pr_head = "current".into();
9417        local.round = 4;
9418        local.checkpoint = 8;
9419        let mut remote = local.clone();
9420        remote.checkpoint = 7;
9421        remote.open_findings.clear();
9422
9423        let chosen = choose_state_for_head(vec![local], "current").unwrap();
9424        assert_eq!(8, chosen.checkpoint);
9425
9426        let mut local = state();
9427        local.pr_head = "current".into();
9428        local.round = 4;
9429        local.checkpoint = 8;
9430        let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
9431        assert_eq!(8, chosen.checkpoint);
9432    }
9433
9434    #[test]
9435    fn legacy_same_round_tie_keeps_the_local_checkpoint() {
9436        let mut local = state();
9437        local.pr_head = "current".into();
9438        local.round = 4;
9439        local.open_findings.push(Finding {
9440            title: "local checkpoint".into(),
9441            ..Finding::default()
9442        });
9443        let mut remote = state();
9444        remote.pr_head = "current".into();
9445        remote.round = 4;
9446
9447        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9448        assert_eq!(
9449            "local checkpoint",
9450            chosen.open_findings.last().unwrap().title
9451        );
9452    }
9453
9454    /// It must render as nothing, so PRs are not littered with machine state.
9455    #[test]
9456    fn the_state_block_is_an_html_comment() {
9457        let body = format!(
9458            "{STATE_MARKER}\n{}\n-->",
9459            serde_json::to_string(&state()).unwrap()
9460        );
9461        assert!(body.starts_with("<!--"));
9462        assert!(body.trim_end().ends_with("-->"));
9463        assert!(!body[..body.find('{').unwrap()].contains("-->"));
9464    }
9465
9466    #[test]
9467    fn an_unrelated_json_block_is_not_state() {
9468        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
9469    }
9470
9471    /// Patching and deleting a comment is not something to do on a substring.
9472    ///
9473    /// A maintainer who notices the block and asks about it writes a comment
9474    /// containing `spar:state`, and the loose filter could not tell theirs from
9475    /// spar's. The next `persist` replaced their words with JSON and
9476    /// `spar clean --pr-state` later deleted it.
9477    #[test]
9478    fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
9479        let block = format!(
9480            "{STATE_MARKER}\n{}\n-->",
9481            serde_json::to_string(&state()).unwrap()
9482        );
9483        assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
9484        assert!(
9485            is_state_comment(&block, "Spar-Bot", "spar-bot"),
9486            "a login differs only in case"
9487        );
9488
9489        assert!(
9490            !is_state_comment(
9491                "why is there a `<!-- spar:state` block on this PR?",
9492                "maintainer",
9493                "spar-bot"
9494            ),
9495            "a question about the block was treated as the block"
9496        );
9497        assert!(
9498            !is_state_comment(&block, "maintainer", "spar-bot"),
9499            "somebody else's comment is not spar's to rewrite"
9500        );
9501        assert!(
9502            !is_state_comment(
9503                &format!("here is what spar writes:\n\n{block}"),
9504                "spar-bot",
9505                "spar-bot"
9506            ),
9507            "the marker has to open the body, not appear in it"
9508        );
9509        assert!(
9510            !is_state_comment(
9511                &format!("{STATE_MARKER}\n{{not json\n-->"),
9512                "spar-bot",
9513                "spar-bot"
9514            ),
9515            "a block that does not parse is not state"
9516        );
9517        assert!(
9518            !is_state_comment(&block, "spar-bot", ""),
9519            "an unknown viewer proves nothing about who wrote it"
9520        );
9521    }
9522
9523    #[test]
9524    fn a_malformed_state_comment_is_none_not_a_panic() {
9525        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
9526    }
9527
9528    #[test]
9529    fn atomic_write_leaves_no_temp_file() {
9530        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
9531        let _ = std::fs::remove_dir_all(&dir);
9532        let path = dir.join("state").join("pr-7.json");
9533        write_json_atomic(&path, &state()).unwrap();
9534        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
9535            .unwrap()
9536            .flatten()
9537            .filter_map(|e| e.file_name().to_str().map(str::to_string))
9538            .collect();
9539        assert_eq!(vec!["pr-7.json".to_string()], files);
9540        let _ = std::fs::remove_dir_all(&dir);
9541    }
9542
9543    #[test]
9544    fn atomic_write_overwrites_rather_than_accumulating() {
9545        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
9546        let _ = std::fs::remove_dir_all(&dir);
9547        let path = dir.join("pr-7.json");
9548        for round in 1..4 {
9549            let mut s = state();
9550            s.round = round;
9551            write_json_atomic(&path, &s).unwrap();
9552        }
9553        let back: PersistedState =
9554            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
9555        assert_eq!(3, back.round);
9556        let _ = std::fs::remove_dir_all(&dir);
9557    }
9558
9559    #[test]
9560    fn style_from_env_defaults_to_enforcing() {
9561        std::env::remove_var("SPAR_BAN_EM_DASH");
9562        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
9563        let style = style_from_env();
9564        assert!(style.ban_em_dash && style.ban_ai_attribution);
9565        assert!(
9566            !style.terse,
9567            "the commit filter must not truncate a commit message"
9568        );
9569    }
9570}
9571
9572#[cfg(test)]
9573mod comment_page_tests {
9574    use super::*;
9575
9576    #[test]
9577    fn a_single_merged_array_is_read() {
9578        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
9579        assert_eq!(2, pages.len());
9580        assert_eq!(Some(2), pages[1]["id"].as_i64());
9581    }
9582
9583    #[test]
9584    fn concatenated_pages_from_an_older_gh_are_read_too() {
9585        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
9586        assert_eq!(2, pages.len());
9587    }
9588
9589    /// A comment body containing a bracket pair used to split the payload into
9590    /// two invalid halves, so no state comment was found and a resume silently
9591    /// started from round one.
9592    #[test]
9593    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
9594        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
9595        let pages = parse_comment_pages(text);
9596        assert_eq!(2, pages.len(), "{pages:?}");
9597        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
9598    }
9599
9600    #[test]
9601    fn empty_output_is_no_comments_not_a_panic() {
9602        assert!(parse_comment_pages("").is_empty());
9603        assert!(parse_comment_pages("   ").is_empty());
9604        assert!(parse_comment_pages("[]").is_empty());
9605    }
9606
9607    #[test]
9608    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
9609        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
9610    }
9611
9612    #[test]
9613    fn a_write_postcheck_rejects_truncated_comment_pages() {
9614        let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
9615        assert!(
9616            error.to_string().contains("unexpected comment pages"),
9617            "{error}"
9618        );
9619    }
9620
9621    #[test]
9622    fn a_write_postcheck_rejects_empty_or_non_array_output() {
9623        assert!(try_parse_comment_pages("").is_err());
9624        assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
9625        assert!(try_parse_comment_pages("[]").is_ok());
9626    }
9627
9628    #[test]
9629    fn state_is_found_in_the_last_matching_comment() {
9630        let payload = |round: u32| {
9631            format!(
9632                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
9633            )
9634        };
9635        let text = serde_json::to_string(&serde_json::json!([
9636            {"id": 1, "body": payload(1)},
9637            {"id": 2, "body": "looks good to me"},
9638            {"id": 3, "body": payload(5)},
9639        ]))
9640        .unwrap();
9641        let pages = parse_comment_pages(&text);
9642        let last = pages
9643            .iter()
9644            .rev()
9645            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
9646            .unwrap();
9647        assert_eq!(5, last.round);
9648    }
9649}
9650
9651#[cfg(test)]
9652mod linked_pr_tests {
9653    use super::*;
9654
9655    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
9656    /// It carries an id and a whole repository object per linked issue, and a
9657    /// parser that chokes on those reports "no pull request", which is the one
9658    /// answer that makes spar implement over the top of somebody's work.
9659    const REAL_PAYLOAD: &str = r#"[
9660      {"number":14252,"title":"fix: reject leading-dash branch names",
9661       "url":"https://github.com/cli/cli/pull/14252",
9662       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
9663         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
9664         "url":"https://github.com/cli/cli/issues/14238"}]},
9665      {"number":14217,"title":"another change",
9666       "url":"https://github.com/cli/cli/pull/14217",
9667       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
9668         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
9669         "url":"https://github.com/cli/cli/issues/9761"}]},
9670      {"number":14200,"title":"unlinked work",
9671       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
9672    ]"#;
9673
9674    #[test]
9675    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
9676        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
9677        assert_eq!(14252, pr.number);
9678        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
9679    }
9680
9681    #[test]
9682    fn the_right_pr_is_picked_out_of_several() {
9683        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
9684    }
9685
9686    #[test]
9687    fn an_issue_nobody_is_working_on_finds_nothing() {
9688        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
9689    }
9690
9691    #[test]
9692    fn an_unlinked_pr_is_never_matched() {
9693        // 14200 closes nothing, so no issue number should ever return it.
9694        for issue in [14200, 0, 1] {
9695            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
9696                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
9697            }
9698        }
9699    }
9700
9701    #[test]
9702    fn empty_or_broken_output_is_none_rather_than_a_panic() {
9703        assert!(find_linked_pr("", 1).is_none());
9704        assert!(find_linked_pr("[]", 1).is_none());
9705        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
9706        assert!(find_linked_pr("[{\"number\":", 1).is_none());
9707    }
9708
9709    /// "gh could not answer" and "nobody is working on it" are different
9710    /// answers, and only one of them means implement from scratch.
9711    ///
9712    /// The parse used to fold both into `None`, so an authentication failure or
9713    /// a rate limit read as an empty list and spar opened a second pull request
9714    /// for an issue somebody already had work open on.
9715    #[test]
9716    fn an_unreadable_list_is_not_an_empty_list() {
9717        assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
9718        assert!(linked_pr_rows("[{\"number\":").is_none());
9719        assert!(linked_pr_rows("").is_none());
9720
9721        let empty = linked_pr_rows("[]").expect("an empty list is an answer");
9722        assert!(empty.is_empty());
9723        assert!(pick_linked_pr(&empty, 14238).is_none());
9724
9725        let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
9726        assert_eq!(3, listed.len());
9727        assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
9728    }
9729
9730    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
9731    #[test]
9732    fn pr_view_reads_the_cross_repository_flag() {
9733        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
9734                       "baseRefName":"main","state":"OPEN",
9735                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
9736        let pr: PrView = serde_json::from_str(json).unwrap();
9737        assert!(pr.is_cross_repository);
9738        assert!(pr.is_open());
9739
9740        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
9741        assert!(
9742            !serde_json::from_str::<PrView>(&same_repo)
9743                .unwrap()
9744                .is_cross_repository
9745        );
9746    }
9747}
9748
9749#[cfg(test)]
9750mod min_number_tests {
9751    /// The floor is applied before the cap, which is the order that matters.
9752    /// spar takes the *lowest* numbered open items, so a repository with a tail
9753    /// of old issues would otherwise spend its whole run in the tail: the cap
9754    /// would be filled by the oldest items and the floor would never be
9755    /// reached. Filtering first is what makes the setting do anything.
9756    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
9757        let mut numbers: Vec<i64> = open.to_vec();
9758        numbers.sort_unstable();
9759        if min_number > 0 {
9760            numbers.retain(|n| *n >= min_number);
9761        }
9762        numbers.truncate(limit);
9763        numbers
9764    }
9765
9766    #[test]
9767    fn the_floor_is_applied_before_the_cap_not_after() {
9768        let open = [12, 13, 14, 480, 481, 482];
9769        assert_eq!(vec![480, 481], pick(&open, 2, 480));
9770        // Capping first would have returned the two oldest and then filtered
9771        // them all away, leaving nothing.
9772        assert!(!pick(&open, 2, 480).is_empty());
9773    }
9774
9775    #[test]
9776    fn no_floor_keeps_the_old_behaviour() {
9777        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
9778    }
9779
9780    #[test]
9781    fn the_floor_is_inclusive() {
9782        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
9783    }
9784
9785    #[test]
9786    fn a_floor_above_everything_open_yields_nothing() {
9787        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
9788    }
9789}