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