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