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