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