Skip to main content

spar/
repo.rs

1//! git and gh. Every outbound string passes through the style and concision
2//! gates before it reaches GitHub.
3
4use std::collections::{BTreeMap, BTreeSet};
5use std::ffi::OsStr;
6use std::fs::OpenOptions;
7use std::io::{BufRead, Read, Write};
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::sync::{Mutex, OnceLock};
11
12use serde::Deserialize;
13use serde_json::Value;
14use sha1::Sha1;
15use sha2::{Digest, Sha256};
16
17use crate::config::{Config, Drafts, Followups, StateStore};
18use crate::error::Result;
19use crate::model::{
20    Contested, ContestedItem, Followup, Issue, IssueRef, ItemKind, PersistedState, PrRef, PrRow,
21    PrSweepRow, PrView, PR_SWEEP_FIELDS,
22};
23use crate::proc::{self, ExecOpts};
24use crate::style::{self, Style};
25use crate::textsim;
26use crate::{bail, logdim, logwarn, spar_err};
27
28/// What is read of one issue that is about to reach a prompt.
29///
30/// The comments come in the same call. The body is what somebody wrote once,
31/// and the thread is where they narrowed it, widened it, or found the real
32/// defect under the one they filed. Linking to the thread instead left that to
33/// an agent that had to guess the body was out of date, and codex, which has no
34/// network, could not follow the link at all.
35const ISSUE_FIELDS: &str = "number,title,body,labels,state,stateReason,url,author,comments";
36
37/// gh returns newest first, so its `--limit` cannot be used to take the lowest
38/// numbered items: it would slice the newest N and then sorting that slice
39/// silently drops the older ones. Fetch a generous page, sort, then truncate.
40pub const FETCH_CEILING: usize = 500;
41
42/// An unclosed HTML comment on purpose. The payload is written after it and
43/// terminated with `-->`, so GitHub renders the whole block as nothing.
44pub const STATE_MARKER: &str = "<!-- spar:state";
45
46/// The signature on a comment spar composed, invisible when rendered.
47///
48/// spar posts as whoever ran it, so a comment from the viewer proves nothing
49/// about who wrote it. Without this, spar's own outcome and round comments
50/// counted as the viewer having answered, and `checkin` skipped a person's
51/// comment as "replied to since" because a bookkeeping comment happened to be
52/// newer than it.
53pub const COMMENT_MARKER: &str = "<!-- spar:comment -->";
54
55/// An entry boundary in the local follow-up note, on the same principle as
56/// `STATE_MARKER` and rendered as nothing for the same reason.
57///
58/// A follow-up's own sections are written as `## Problem` and friends, at the
59/// same heading level as the entry's title, so the file's shape does not say
60/// which of two `## ` lines starts an entry. This does. Files written before it
61/// existed are still read, by the heuristic in `followups::parse`.
62pub const FOLLOWUP_MARKER: &str = "<!-- spar:followup -->";
63
64const WORKTREE_DIR: &str = ".spar-worktrees";
65const STATE_DIR: &str = ".spar";
66
67/// Files SPAR writes into a worktree itself, which no agent ever authors.
68pub(crate) const RECOVERY_MARKER_PREFIX: &str = ".spar-recovery-needed-";
69pub(crate) const EDITED_GIT_MARKER: &str = ".spar-edited-git-marker";
70
71/// What SPAR is allowed to throw away when it decides a checkout can go.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub(crate) enum Disposable {
74    /// Recognized build output, and nothing else. Somebody may have put the
75    /// rest there and want it back.
76    BuildOutput,
77    /// Every ignored file, and SPAR's own markers. Only rebuilding a review
78    /// checkout qualifies, and only because the next statement replaces it with
79    /// a fresh checkout of the same ref: what is there came from an earlier
80    /// `spar review` of the same pull request, and `git worktree add` would
81    /// write over it regardless. Refusing instead left the command unusable on
82    /// any project whose build writes outside `target/`, since a single
83    /// lockfile beside a generator's manifest blocked every later run.
84    ReviewOutput,
85}
86
87/// Whether `path` is one of the files SPAR leaves in a worktree root.
88///
89/// A marker is evidence for a person, not something a call did to the tree, and
90/// two reviewers share one checkout: the marker written when the first one's
91/// answer was refused is otherwise a brand new untracked file in the snapshot
92/// the second one is compared against, so one refusal took the other review
93/// down with it.
94pub(crate) fn is_spar_marker(path: &Path) -> bool {
95    let mut components = path.components();
96    let Some(std::path::Component::Normal(name)) = components.next() else {
97        return false;
98    };
99    if components.next().is_some() {
100        return false;
101    }
102    let Some(name) = name.to_str() else {
103        return false;
104    };
105    name == EDITED_GIT_MARKER || name.starts_with(RECOVERY_MARKER_PREFIX)
106}
107
108/// Whether `path` is somewhere SPAR writes in the repository it is driving.
109///
110/// The markers are one part of it. The other is the run state under `.spar/`,
111/// which SPAR rewrites while a call it started is still running: recording the
112/// branches it created during a parallel triage is enough to make both agents
113/// look like they changed the tree they were told only to read. SPAR's own
114/// bookkeeping is not evidence about an agent, so it never enters the snapshot
115/// a call is compared against.
116///
117/// `.spar-worktrees/` is deliberately not here. A resident worktree enters the
118/// snapshot as one ignored entry so that another run's churn inside it does not
119/// disturb this baseline, while deleting the whole checkout still does.
120pub(crate) fn is_spar_own_path(path: &Path) -> bool {
121    if is_spar_marker(path) {
122        return true;
123    }
124    let Some(std::path::Component::Normal(name)) = path.components().next() else {
125        return false;
126    };
127    name == OsStr::new(STATE_DIR)
128}
129
130/// How many names one part of a split may be tried on before giving up. High
131/// enough that nobody reaches it by splitting the same pull request again, low
132/// enough that a repository where every name is taken says so rather than
133/// looping.
134const SPLIT_SLOTS: u32 = 20;
135
136#[derive(Debug, Clone)]
137pub struct SplitPushError {
138    message: String,
139    retain_worktree: bool,
140}
141
142impl SplitPushError {
143    pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
144        Self {
145            message: message.into(),
146            retain_worktree,
147        }
148    }
149
150    pub fn retain_worktree(&self) -> bool {
151        self.retain_worktree
152    }
153}
154
155impl std::fmt::Display for SplitPushError {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        f.write_str(&self.message)
158    }
159}
160
161impl std::error::Error for SplitPushError {}
162
163/// The branch and worktree name for one part, on its `attempt`th name.
164///
165/// The unsuffixed name first, so the ordinary case reads as `split-12-1` and
166/// only a repeat split carries a suffix.
167fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
168    match attempt {
169        1 => format!("split-{parent}-{index}"),
170        n => format!("split-{parent}-{index}-{n}"),
171    }
172}
173
174#[derive(Debug)]
175pub struct Repo {
176    root: PathBuf,
177    pub style: Style,
178    pub branch_prefix: String,
179    pub state_store: StateStore,
180    pub followups: Followups,
181    pub drafts: Drafts,
182    /// The login `gh` is authenticated as, asked at most once.
183    ///
184    /// `OnceLock` rather than `OnceCell` because `&Repo` crosses a
185    /// `std::thread::scope` whenever both agents are asked at the same time,
186    /// and only `OnceLock` is `Sync`.
187    viewer: OnceLock<String>,
188    /// Highest persisted checkpoint observed for each pull request.
189    ///
190    /// Kept in memory so a transient state read cannot reset the sequence
191    /// after a resume already loaded a newer checkpoint.
192    checkpoints: Mutex<BTreeMap<i64, u64>>,
193    writes: WriteStats,
194}
195
196#[derive(Debug, Default)]
197struct WriteStats {
198    attempted: AtomicUsize,
199    failed: AtomicUsize,
200}
201
202#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
203pub(crate) struct WriteSummary {
204    pub(crate) attempted: usize,
205    pub(crate) failed: usize,
206}
207
208impl WriteSummary {
209    pub(crate) fn succeeded(self) -> usize {
210        self.attempted.saturating_sub(self.failed)
211    }
212}
213
214/// Ignored, untracked paths present before an editing call starts.
215///
216/// Existing build artifacts are deliberately part of the baseline. Callers can
217/// therefore distinguish them from an ignored file the editing call created
218/// and avoid deleting the latter as if no work had happened.
219#[derive(Debug, Clone)]
220pub(crate) struct WorktreeBaseline {
221    attributes: AttributeState,
222    ignored_untracked: IgnoredState,
223    git_state: GitState,
224}
225
226/// The recorded Git state of a worktree that must remain read only.
227///
228/// Read-only agent calls are still ordinary processes. Capturing the complete
229/// working state lets callers refuse to publish their answer or delete the
230/// checkout if a call writes despite its instructions.
231#[derive(Debug, Clone)]
232pub(crate) struct WorktreeCheckpoint {
233    path: PathBuf,
234    attributes: AttributeState,
235    git_state: GitState,
236    ignored_untracked: IgnoredState,
237}
238
239#[derive(Debug, Clone, Default, PartialEq, Eq)]
240pub(crate) struct AttributeState {
241    files: BTreeMap<PathBuf, [u8; 32]>,
242}
243
244/// Exact paths and fingerprints for every untracked file, plus ignored paths.
245///
246/// Paths stay as operating-system strings so a non-UTF-8 filename cannot be
247/// merged with another path by lossy command-output conversion.
248#[derive(Debug, Clone, Default, PartialEq, Eq)]
249pub(crate) struct IgnoredState {
250    files: BTreeMap<PathBuf, UntrackedFile>,
251    ignored: BTreeSet<PathBuf>,
252}
253
254/// A bounded-cost fingerprint for an untracked filesystem entry.
255///
256/// Content hashing every ignored compiler artifact made each checkpoint read
257/// gigabytes. File identity, type, size, timestamps, and mode detect ordinary
258/// writes without rereading build output. Unix change time also changes when a
259/// writer restores the modification time.
260#[derive(Debug, Clone, PartialEq, Eq)]
261struct UntrackedFile {
262    kind: u8,
263    len: u64,
264    modified: Option<std::time::SystemTime>,
265    created: Option<std::time::SystemTime>,
266    readonly: bool,
267    symlink_target: Option<Vec<u8>>,
268    #[cfg(unix)]
269    device: u64,
270    #[cfg(unix)]
271    inode: u64,
272    #[cfg(unix)]
273    mode: u32,
274    #[cfg(unix)]
275    change_seconds: i64,
276    #[cfg(unix)]
277    change_nanoseconds: i64,
278}
279
280#[derive(Debug, Clone, Default, PartialEq, Eq)]
281pub(crate) struct GitState {
282    repositories: BTreeMap<PathBuf, RepositoryState>,
283}
284
285#[derive(Debug, Clone, PartialEq, Eq)]
286struct RepositoryState {
287    head: String,
288    unsafe_index_flags: Vec<u8>,
289    tracked: BTreeMap<PathBuf, TrackedEntry>,
290    gitlinks: BTreeMap<PathBuf, String>,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
294struct TrackedEntry {
295    index_mode: String,
296    index_oid: String,
297    worktree: Option<WorktreeFile>,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
301struct WorktreeFile {
302    mode: String,
303    #[cfg(unix)]
304    permissions: u32,
305    raw_oid: String,
306    fingerprint: [u8; 32],
307    content: [u8; 32],
308}
309
310struct Gitlink {
311    path: PathBuf,
312    oid: String,
313}
314
315struct IndexEntry {
316    path: PathBuf,
317    mode: String,
318    oid: String,
319}
320
321impl IgnoredState {
322    fn is_ignored(&self, path: &Path) -> bool {
323        self.ignored.contains(path)
324    }
325
326    fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
327        let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
328        paths.extend(after.files.keys().cloned());
329        paths
330            .into_iter()
331            .filter(|path| {
332                self.files.get(path) != after.files.get(path)
333                    || self.is_ignored(path) != after.is_ignored(path)
334            })
335            .collect()
336    }
337
338    fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
339        self.files
340            .iter()
341            .filter(|(path, state)| after.files.get(*path) != Some(*state))
342            .map(|(path, _)| path.clone())
343            .collect()
344    }
345
346    fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
347        after
348            .files
349            .keys()
350            .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
351            .cloned()
352            .collect()
353    }
354
355    /// Whether anything moved here that is not recognized build or cache
356    /// output.
357    ///
358    /// A read-only call is held to its word by comparing the worktree before
359    /// and after. Verifying a finding usually means building the project and
360    /// running its tests, which rewrites that output, and holding a reviewer to
361    /// a byte-identical `dist/` threw away the review it had just done. What
362    /// the call was asked to judge is the tracked tree, and that is compared as
363    /// strictly as ever, along with every other untracked file.
364    pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
365        let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
366        paths.extend(after.files.keys());
367        paths.into_iter().any(|path| {
368            if self.files.get(path) == after.files.get(path)
369                && self.is_ignored(path) == after.is_ignored(path)
370            {
371                return false;
372            }
373            !self.generated_move(after, path)
374        })
375    }
376
377    /// What a read-only call left behind, split by whether it condemns the
378    /// answer.
379    ///
380    /// `changed_beyond_generated` answers this from a list of directory names,
381    /// which is a guess about where a build writes. The repository already
382    /// states where it writes, in its own ignore rules, and a review died over
383    /// the gap: a reviewer ran the project's binding generator to check a
384    /// finding, that wrote a lockfile beside its own manifest rather than under
385    /// `target/`, and both agents lost an hour of work to a path Git had been
386    /// told to disown.
387    ///
388    /// Forgiving only the creation of such a path was not enough, because the
389    /// call is not the only writer. A read-only call runs for minutes in the
390    /// primary checkout, which is a live desk: Finder rewrites `.DS_Store`, an
391    /// editor rewrites its own state, and a triage that asks two agents at once
392    /// compares both of them against that same directory. One ambient write
393    /// discarded two good answers and ended the run with nothing scheduled. So
394    /// a path the project ignores is out of scope however it moved, and what
395    /// remains fatal is what the call was actually asked about: the tracked
396    /// tree, and any ordinary untracked file, because a reviewer was told to
397    /// keep its scratch out of the worktree.
398    ///
399    /// Nothing is silent. A tolerated path that is not recognized build output
400    /// is named by the caller, so an agent that rewrote a person's `.env` is
401    /// reported rather than hidden. That is the trade this makes: the answer of
402    /// a call that never commits is worth more than the guarantee, and the
403    /// warning carries what the guarantee used to.
404    pub(crate) fn read_only_leavings(&self, after: &Self) -> ReadOnlyLeavings {
405        let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
406        paths.extend(after.files.keys());
407        let mut leavings = ReadOnlyLeavings::default();
408        for path in paths {
409            if self.files.get(path) == after.files.get(path)
410                && self.is_ignored(path) == after.is_ignored(path)
411            {
412                continue;
413            }
414            if !self.ignored_file_move(after, path) {
415                leavings.condemning.push(path.clone());
416            } else if !is_generated_artifact(path) {
417                leavings.tolerated.push(path.clone());
418            }
419        }
420        leavings
421    }
422
423    /// Whether what happened at `path` between these two states is a build or
424    /// cache directory doing its job, rather than a change a person would miss.
425    ///
426    /// Removal counts, on either side. A compiler that keeps an incremental
427    /// cache does not only add and rewrite: it finalizes a new session
428    /// directory and deletes the one it superseded, lock file and all. A rule
429    /// that forgave creation and rewriting but not removal therefore passed the
430    /// first build in a fresh worktree and failed the second one, which is the
431    /// build a reviewer runs to check a finding. Two of these rules disagreeing
432    /// about the same directory is what cost the run: the read-only check
433    /// forgave the deleted session and the commit-side check called it
434    /// unrepresentable, so the work was already on a pull request when the run
435    /// ended. There is one rule now, and this is it.
436    fn generated_move(&self, after: &Self, path: &Path) -> bool {
437        is_generated_artifact(path) && self.disposable_move(after, path)
438    }
439
440    /// Successful edits and read-only calls share an ignore policy. Spar's
441    /// resident worktrees belong to other runs, not to this call's output.
442    /// Cleanup separately preserves ignored data, including dependency clones.
443    fn ignored_file_move(&self, after: &Self, path: &Path) -> bool {
444        if path.starts_with(WORKTREE_DIR)
445            && self.files.get(path).is_some_and(|file| file.kind == 3)
446            && self.files.get(path) != after.files.get(path)
447        {
448            return false;
449        }
450        self.disposable_move(after, path)
451    }
452
453    /// Whether neither state has anything at `path` worth keeping, so whatever
454    /// happened there took nothing with it.
455    fn disposable_move(&self, after: &Self, path: &Path) -> bool {
456        self.disposable(path) && after.disposable(path)
457    }
458
459    /// Whether this state has nothing at `path` worth keeping: either the path
460    /// is not there at all, or it is there as an ignored file.
461    fn disposable(&self, path: &Path) -> bool {
462        !self.files.contains_key(path) || self.is_ignored(path)
463    }
464}
465
466impl GitState {
467    /// What moved between two states, said so a person can act on it.
468    ///
469    /// The guards that compare these used to report only that the worktree had
470    /// changed, which left the one question worth answering, which file, to a
471    /// `git status` run by hand against a tree that had moved on since.
472    pub(crate) fn describe_difference(&self, after: &Self) -> Option<String> {
473        let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
474        prefixes.extend(after.repositories.keys());
475        let mut notes: Vec<String> = Vec::new();
476        let mut paths: Vec<PathBuf> = Vec::new();
477        for prefix in prefixes {
478            let label = describe_repository(prefix);
479            match (
480                self.repositories.get(prefix),
481                after.repositories.get(prefix),
482            ) {
483                (None, Some(_)) => notes.push(format!("a repository appeared at {label}")),
484                (Some(_), None) => notes.push(format!("the repository at {label} is gone")),
485                (Some(before), Some(current)) => {
486                    if before.head != current.head {
487                        notes.push(format!("HEAD moved in {label}"));
488                    }
489                    if before.unsafe_index_flags != current.unsafe_index_flags {
490                        notes.push(format!("index flags changed in {label}"));
491                    }
492                    paths.extend(
493                        differing_keys(&before.gitlinks, &current.gitlinks)
494                            .chain(differing_keys(&before.tracked, &current.tracked))
495                            .map(|path| prefix.join(path)),
496                    );
497                }
498                (None, None) => {}
499            }
500        }
501        if !paths.is_empty() {
502            paths.sort();
503            paths.dedup();
504            notes.push(format!("tracked file(s) changed: {}", list_paths(&paths)));
505        }
506        (!notes.is_empty()).then(|| notes.join("; "))
507    }
508
509    /// The tracked half of what a read-only call left behind, with a checkout
510    /// that moved under the call reported rather than held against it.
511    pub(crate) fn read_only_difference(&self, after: &Self, cwd: &Path) -> Option<String> {
512        let difference = self.describe_difference(after)?;
513        let Some(moved) = self.moved_wholesale(after) else {
514            return Some(difference);
515        };
516        // Once, however many calls were reading when it happened: two triage
517        // agents share the checkout and would otherwise both report the move.
518        if said_once("moved", cwd, &moved) {
519            logwarn!(
520                "the checkout at {} moved while a read-only call was reading it: {moved}. The \
521                 answer was kept, and it describes the tree as it stood before the move.",
522                cwd.display()
523            );
524        }
525        None
526    }
527
528    /// Whether every difference between these two states is the checkout moving
529    /// as a whole rather than the call writing into it.
530    ///
531    /// The primary checkout is a live desk and a read-only call reads it for
532    /// minutes. A person or their editor pulls, the branch fast forwards, and
533    /// every file that arrived with it now differs from the snapshot taken
534    /// before the call. Holding the call to that snapshot threw away two good
535    /// triage answers and ended a run with nothing scheduled, over a change the
536    /// call had no part in.
537    ///
538    /// The index tells the two apart. A call that wrote leaves a path
539    /// disagreeing with the index, staged or unstaged, and leaves HEAD where it
540    /// was. A checkout that moved rewrote the index and the worktree together,
541    /// so every path it touched agrees with the index, and it moved HEAD to say
542    /// so. Anything else, a tracked file that changed while HEAD stood still
543    /// most of all, is the call's to answer for.
544    fn moved_wholesale(&self, after: &Self) -> Option<String> {
545        let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
546        prefixes.extend(after.repositories.keys());
547        let mut notes: Vec<String> = Vec::new();
548        for prefix in prefixes {
549            // A repository that appeared or vanished is not a checkout moving.
550            let before = self.repositories.get(prefix)?;
551            let current = after.repositories.get(prefix)?;
552            if before.unsafe_index_flags != current.unsafe_index_flags
553                || before.gitlinks != current.gitlinks
554            {
555                return None;
556            }
557            let moved: Vec<&PathBuf> = differing_keys(&before.tracked, &current.tracked).collect();
558            if before.head == current.head {
559                if moved.is_empty() {
560                    continue;
561                }
562                return None;
563            }
564            if !moved.iter().all(|path| {
565                checked_out(before.tracked.get(*path)) && checked_out(current.tracked.get(*path))
566            }) {
567                return None;
568            }
569            let label = describe_repository(prefix);
570            notes.push(match moved.len() {
571                0 => format!("HEAD moved in {label}"),
572                count => format!("HEAD moved in {label}, carrying {count} tracked file(s)"),
573            });
574        }
575        (!notes.is_empty()).then(|| notes.join("; "))
576    }
577}
578
579/// Whether a tracked path holds exactly what the index says it holds, so
580/// nothing there is staged or left unstaged.
581///
582/// A path the index no longer lists counts as checked out: only an index
583/// operation drops one, and a file left in the worktree after that is an
584/// ordinary untracked file, which the untracked half of the same check has.
585fn checked_out(entry: Option<&TrackedEntry>) -> bool {
586    entry.is_none_or(|entry| {
587        entry
588            .worktree
589            .as_ref()
590            .is_some_and(|file| file.raw_oid == entry.index_oid && file.mode == entry.index_mode)
591    })
592}
593
594/// Say what a call left among paths the project ignores, once per run for a
595/// given set of them.
596///
597/// The project's own ignore rules put these outside what the call was asked
598/// about, so this is a note about the desk the call ran on rather than a
599/// finding about the call. Said as a warning on every call, it became the
600/// loudest thing in the log of every run in a checkout with an editor open in
601/// it: `.DS_Store` twice per triage, and again for each review. It still names
602/// the paths, so an agent that rewrote a person's `.env` is reported rather
603/// than hidden.
604pub(crate) fn note_ignored_leavings(cwd: &Path, what: &str, paths: &[PathBuf]) {
605    if paths.is_empty() {
606        return;
607    }
608    let named = list_paths(paths);
609    if !said_once("ignored", cwd, &named) {
610        return;
611    }
612    logdim!(
613        "a read-only {what} left {} ignored file(s) changed in {}: {named}. It was kept; the \
614         project ignores these paths.",
615        paths.len(),
616        cwd.display()
617    );
618}
619
620/// Whether this run has yet to say this about this directory.
621///
622/// A run reads the same checkout dozens of times, and the ambient facts about
623/// it, an editor's own files moving and a branch advancing under the reader,
624/// are true for every one of those calls. Said once they are context; said
625/// each time they are the log.
626fn said_once(topic: &str, cwd: &Path, detail: &str) -> bool {
627    static SAID: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
628    SAID.lock()
629        .unwrap_or_else(std::sync::PoisonError::into_inner)
630        .insert(format!("{topic}\0{}\0{detail}", cwd.display()))
631}
632
633/// Name a repository inside a state for a report. The root carries an empty
634/// prefix, and calling that "" helps nobody.
635fn describe_repository(prefix: &Path) -> String {
636    if prefix.as_os_str().is_empty() {
637        "the worktree".to_string()
638    } else {
639        format!("the submodule at {:?}", prefix.as_os_str())
640    }
641}
642
643/// The keys whose values differ between two maps, present on either side.
644fn differing_keys<'a, V: PartialEq>(
645    before: &'a BTreeMap<PathBuf, V>,
646    after: &'a BTreeMap<PathBuf, V>,
647) -> impl Iterator<Item = &'a PathBuf> {
648    let mut keys: BTreeSet<&'a PathBuf> = before.keys().collect();
649    keys.extend(after.keys());
650    keys.into_iter()
651        .filter(move |key| before.get(*key) != after.get(*key))
652}
653
654/// What a read-only call left in the untracked and ignored tree.
655///
656/// Split rather than reduced to a yes or no, because the two halves are
657/// answered differently: one ends the call, the other is said out loud and the
658/// answer is kept.
659#[derive(Default)]
660pub(crate) struct ReadOnlyLeavings {
661    /// Disposable on both sides, so out of scope for a call that only read.
662    /// Recognized build output is left out: rebuilding is what a reviewer was
663    /// asked to do, and nothing reports it today.
664    pub(crate) tolerated: Vec<PathBuf>,
665    /// Everything else. The answer does not survive these.
666    pub(crate) condemning: Vec<PathBuf>,
667}
668
669/// Put two accounts of what moved into one sentence, keeping whichever of them
670/// there is. Both guards that report a changed worktree ask the tracked tree
671/// and the untracked tree separately, and either can be the only answer.
672pub(crate) fn join_reasons(first: Option<String>, second: Option<String>) -> Option<String> {
673    match (first, second) {
674        (None, None) => None,
675        (Some(one), None) | (None, Some(one)) => Some(one),
676        (Some(first), Some(second)) => Some(format!("{first}; {second}")),
677    }
678}
679
680/// Name some paths for a person, without printing a build directory at them.
681///
682/// Every report of a path that stopped or was let through a call goes through
683/// this, so an error and the warning beside it read alike.
684pub(crate) fn list_paths(paths: &[PathBuf]) -> String {
685    const SHOWN: usize = 5;
686    let mut listed = paths
687        .iter()
688        .take(SHOWN)
689        .map(|path| format!("{:?}", path.as_os_str()))
690        .collect::<Vec<_>>()
691        .join(", ");
692    if paths.len() > SHOWN {
693        listed.push_str(&format!(", and {} more", paths.len() - SHOWN));
694    }
695    listed
696}
697
698/// Ignored files one commit attempt let through, gathered for a single report.
699///
700/// The checks that allow it run more than once per attempt, before staging,
701/// after staging, and again after the commit, because the tree could have moved
702/// under any of them. Reporting from inside each check said the same thing
703/// about the same files two and three times over.
704#[derive(Default)]
705struct IgnoredArtifacts {
706    new_paths: BTreeSet<PathBuf>,
707    changed_paths: BTreeSet<PathBuf>,
708    removed_paths: BTreeSet<PathBuf>,
709}
710
711impl IgnoredArtifacts {
712    fn left(&mut self, allowed: AllowedArtifacts) {
713        self.new_paths.extend(allowed.present);
714        self.removed_paths.extend(allowed.removed);
715    }
716
717    fn changed(&mut self, allowed: AllowedArtifacts) {
718        self.changed_paths.extend(allowed.present);
719        self.removed_paths.extend(allowed.removed);
720    }
721
722    /// Report paths without claiming ignored files are safe to delete.
723    fn report(&self, cwd: &Path) {
724        let paths: BTreeSet<_> = self
725            .new_paths
726            .iter()
727            .chain(&self.changed_paths)
728            .chain(&self.removed_paths)
729            .cloned()
730            .collect();
731        if !paths.is_empty() {
732            logdim!(
733                "the editing call changed {} ignored file(s) in {}: {}. They are outside \
734                 the commit; cleanup checks them separately.",
735                paths.len(),
736                cwd.display(),
737                list_paths(&paths.into_iter().collect::<Vec<_>>())
738            );
739        }
740    }
741}
742
743/// Ignored files a check let through, split by whether each one
744/// is still on disk, so the report can say what happened rather than call a
745/// deleted incremental session a file that was left behind.
746#[derive(Default)]
747struct AllowedArtifacts {
748    present: Vec<PathBuf>,
749    removed: Vec<PathBuf>,
750}
751
752impl AllowedArtifacts {
753    fn split(paths: Vec<PathBuf>, after: &IgnoredState) -> Self {
754        let (present, removed) = paths
755            .into_iter()
756            .partition(|path| after.files.contains_key(path));
757        Self { present, removed }
758    }
759}
760
761/// Generated directories that test and build commands routinely create.
762///
763/// Files under these directories are never committed, and they do not keep a
764/// worktree that is otherwise finished, so running the requested tests neither
765/// stops the tracked change reaching review nor leaves a checkout behind.
766fn is_generated_artifact(path: &Path) -> bool {
767    const DIRECTORIES: &[&str] = &[
768        "target",
769        "dist",
770        "node_modules",
771        "__pycache__",
772        ".pytest_cache",
773        ".mypy_cache",
774        ".ruff_cache",
775        ".tox",
776        ".nox",
777        ".venv",
778        "venv",
779        ".gradle",
780        ".build",
781        "DerivedData",
782        ".next",
783        ".nuxt",
784        ".svelte-kit",
785        ".turbo",
786        "coverage",
787    ];
788    path.components().any(|component| {
789        let std::path::Component::Normal(name) = component else {
790            return false;
791        };
792        DIRECTORIES
793            .iter()
794            .any(|directory| name == OsStr::new(directory))
795    })
796}
797
798/// Files the desktop writes into any folder a person opens.
799///
800/// Finder drops `.DS_Store` into every directory it shows, so a finished
801/// worktree someone once looked at in Finder held nothing but these, and
802/// `spar clean` kept every one of them as if it were work.
803fn is_desktop_metadata(path: &Path) -> bool {
804    path.file_name() == Some(OsStr::new(".DS_Store"))
805}
806
807fn merge_pr_args<'a>(
808    number: &'a str,
809    expected_head: Option<&'a str>,
810    delete_branch: bool,
811) -> Vec<&'a str> {
812    let mut args = vec!["pr", "merge", number, "--squash"];
813    if delete_branch {
814        args.push("--delete-branch");
815    }
816    if let Some(expected_head) = expected_head {
817        args.extend(["--match-head-commit", expected_head]);
818    }
819    args
820}
821
822fn reconcile_pr_creation(
823    branch: &str,
824    created: Result<String>,
825    found: Result<Option<PrRef>>,
826) -> Result<PrRef> {
827    match (created, found) {
828        (_, Ok(Some(pr))) => Ok(pr),
829        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
830            "PR creation reported success but none was found for {branch}"
831        ))),
832        (Err(create), Ok(None)) => Err(spar_err!(
833            "could not open a PR for {branch}. {}",
834            create.last_line()
835        )),
836        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
837            "PR creation reported success for {branch}, but it could not be verified. {}",
838            check.last_line()
839        ))),
840        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
841            "could not open a PR for {branch}. {} The result could not be verified: {}",
842            create.last_line(),
843            check.last_line()
844        ))),
845    }
846}
847
848fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
849    #[derive(Deserialize)]
850    #[serde(rename_all = "camelCase")]
851    struct Row {
852        number: i64,
853        #[serde(default)]
854        url: String,
855        #[serde(default)]
856        title: String,
857        base_ref_name: String,
858    }
859
860    let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
861        spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
862    })?;
863    Ok(rows
864        .into_iter()
865        .find(|row| row.base_ref_name == base)
866        .map(|row| PrRef {
867            number: row.number,
868            url: row.url,
869            title: row.title,
870        }))
871}
872
873/// Sign a comment spar composed, so it can be told from one the same account
874/// wrote by hand. Idempotent: a body already signed is returned as it is.
875fn signed(body: &str) -> String {
876    if body.contains(COMMENT_MARKER) {
877        return body.to_string();
878    }
879    format!("{body}\n\n{COMMENT_MARKER}")
880}
881
882/// Whether a comment body is one spar composed rather than one a person wrote.
883pub fn is_spar_comment(body: &str) -> bool {
884    body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
885}
886
887/// What `gh api -i` printed, taken apart.
888#[derive(Debug, Clone, Default, PartialEq, Eq)]
889pub struct ApiResponse {
890    pub status: u16,
891    /// Header names lowercased, values trimmed.
892    pub headers: BTreeMap<String, String>,
893    pub body: String,
894}
895
896/// Split `gh api -i` output into the status, the headers, and the body. None
897/// when it does not start with a status line, which is what a spawn that
898/// printed an error looks like.
899pub fn parse_api_response(stdout: &str) -> Option<ApiResponse> {
900    let mut lines = stdout.split_inclusive('\n');
901    let first = lines.next()?.trim_end_matches(['\r', '\n']);
902    if !first.starts_with("HTTP/") {
903        return None;
904    }
905    let status: u16 = first.split_whitespace().nth(1)?.parse().ok()?;
906    let mut headers = BTreeMap::new();
907    let mut body = String::new();
908    let mut in_body = false;
909    for line in lines {
910        if in_body {
911            body.push_str(line);
912            continue;
913        }
914        let bare = line.trim_end_matches(['\r', '\n']);
915        if bare.is_empty() {
916            in_body = true;
917            continue;
918        }
919        if let Some((name, value)) = bare.split_once(':') {
920            headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
921        }
922    }
923    Some(ApiResponse {
924        status,
925        headers,
926        body,
927    })
928}
929
930/// The pull request a per item state file is about: `pr-12.json`,
931/// `review-12.json`, `checkin-12.json`. None for anything else, so the shared
932/// files beside them, `spend.json` and the rest, are never touched.
933pub(crate) fn state_file_number(name: &str) -> Option<i64> {
934    let stem = name.strip_suffix(".json")?;
935    ["pr-", "review-", "checkin-"]
936        .iter()
937        .find_map(|prefix| stem.strip_prefix(prefix))
938        .filter(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
939        .and_then(|digits| digits.parse().ok())
940}
941
942/// Whether the viewer has already submitted this review: exactly this body, on
943/// this commit, and still standing. A clean review reads the same on every
944/// commit, so a body alone would take an approval of an older commit for this
945/// one; and a dismissed review was withdrawn, so it is not this one either.
946fn has_viewer_review(reviews: &[Value], viewer: &str, body: &str, commit: &str) -> bool {
947    reviews.iter().any(|review| {
948        let field = |name: &str| review.get(name).and_then(Value::as_str).unwrap_or("");
949        let standing = !matches!(
950            field("state").to_uppercase().as_str(),
951            "DISMISSED" | "PENDING"
952        );
953        review
954            .get("user")
955            .and_then(|u| u.get("login"))
956            .and_then(Value::as_str)
957            .is_some_and(|login| crate::comments::same_login(login, viewer))
958            && field("body") == body
959            && field("commit_id").eq_ignore_ascii_case(commit)
960            && standing
961    })
962}
963
964fn reconcile_review_post(
965    number: i64,
966    body: &str,
967    viewer: &str,
968    commit: &str,
969    post_error: crate::error::SparError,
970    reviews: Result<Vec<Value>>,
971) -> Result<()> {
972    match reviews {
973        Ok(reviews) if has_viewer_review(&reviews, viewer, body, commit) => Ok(()),
974        Ok(_) => Err(post_error),
975        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
976            "could not submit a review on #{number}. {} The result could not be verified: {}",
977            post_error.last_line(),
978            read_error.last_line()
979        ))),
980    }
981}
982
983fn has_exact_comment(comments: &[Value], body: &str) -> bool {
984    comments.iter().any(|comment| {
985        comment
986            .get("body")
987            .and_then(Value::as_str)
988            .is_some_and(|seen| seen == body)
989    })
990}
991
992fn reconcile_comment_post(
993    number: i64,
994    body: &str,
995    post_error: crate::error::SparError,
996    comments: Result<Vec<Value>>,
997) -> Result<()> {
998    match comments {
999        Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
1000        Ok(_) => Err(post_error),
1001        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1002            "could not comment on #{number}. {} The result could not be verified: {}",
1003            post_error.last_line(),
1004            read_error.last_line()
1005        ))),
1006    }
1007}
1008
1009fn reconcile_issue_edit(
1010    number: i64,
1011    wanted: &str,
1012    edit_error: crate::error::SparError,
1013    observed: Result<String>,
1014) -> Result<()> {
1015    match observed {
1016        Ok(body) if body == wanted => Ok(()),
1017        Ok(_) => Err(spar_err!(
1018            "could not rewrite the body of #{number}. {}",
1019            edit_error.last_line()
1020        )),
1021        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1022            "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1023            edit_error.last_line(),
1024            read_error.last_line()
1025        ))),
1026    }
1027}
1028
1029fn issue_url_has_number(url: &str) -> bool {
1030    url.trim()
1031        .rsplit('/')
1032        .next()
1033        .and_then(|tail| tail.parse::<i64>().ok())
1034        .is_some_and(|number| number > 0)
1035}
1036
1037fn reconcile_issue_creation(
1038    title: &str,
1039    created: Result<String>,
1040    found: Result<Option<ExistingIssue>>,
1041) -> Result<String> {
1042    match (created, found) {
1043        (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1044        (_, Ok(Some(issue))) => Ok(issue.url),
1045        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1046            "issue creation reported success but no matching issue was found for {title:?}"
1047        ))),
1048        (Err(create), Ok(None)) => Err(spar_err!(
1049            "could not file issue {title:?}. {}",
1050            create.last_line()
1051        )),
1052        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1053            "issue creation reported success for {title:?}, but it could not be verified. {}",
1054            check.last_line()
1055        ))),
1056        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1057            "could not file issue {title:?}. {} The result could not be verified: {}",
1058            create.last_line(),
1059            check.last_line()
1060        ))),
1061    }
1062}
1063
1064fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1065    if output.trim().is_empty() {
1066        return Ok(None);
1067    }
1068    for line in output.lines() {
1069        let mut fields = line.split_whitespace();
1070        let oid = fields.next().unwrap_or_default();
1071        let name = fields.next().unwrap_or_default();
1072        if name == remote_ref && !oid.is_empty() {
1073            return Ok(Some(oid.to_string()));
1074        }
1075    }
1076    Err(spar_err!(
1077        "origin returned an unexpected ref listing for {remote_ref}"
1078    ))
1079}
1080
1081fn reconcile_failed_split_push(
1082    branch: &str,
1083    push_error: crate::error::SparError,
1084    local: Result<String>,
1085    remote: Result<String>,
1086) -> std::result::Result<(), SplitPushError> {
1087    let remote_ref = format!("refs/heads/{branch}");
1088    match (local, remote) {
1089        (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1090            Ok(Some(oid)) if oid == local.trim() => Ok(()),
1091            Ok(_) => Err(SplitPushError::new(
1092                format!(
1093                    "could not create origin/{branch}. {} The remote branch is absent or points \
1094                     somewhere else. Nothing was overwritten.",
1095                    push_error.last_line()
1096                ),
1097                false,
1098            )),
1099            Err(check) => Err(SplitPushError::new(
1100                format!(
1101                    "could not confirm whether origin/{branch} was created. {} The remote result \
1102                     could not be verified: {}",
1103                    push_error.last_line(),
1104                    check.last_line()
1105                ),
1106                true,
1107            )),
1108        },
1109        (local, remote) => {
1110            let check = match (local, remote) {
1111                (Err(local), Err(remote)) => format!(
1112                    "the local commit could not be read: {}; origin could not be read: {}",
1113                    local.last_line(),
1114                    remote.last_line()
1115                ),
1116                (Err(local), _) => {
1117                    format!("the local commit could not be read: {}", local.last_line())
1118                }
1119                (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1120                _ => unreachable!(),
1121            };
1122            Err(SplitPushError::new(
1123                format!(
1124                    "could not confirm whether origin/{branch} was created. {} The result could \
1125                     not be verified because {check}",
1126                    push_error.last_line()
1127                ),
1128                true,
1129            ))
1130        }
1131    }
1132}
1133
1134impl Repo {
1135    pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1136        let root =
1137            std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1138        // A linked worktree has a `.git` file rather than a directory, and a
1139        // bare-ish layout can have neither, so ask git instead of guessing.
1140        let inside = proc::run_str(
1141            &["git", "rev-parse", "--is-inside-work-tree"],
1142            &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1143        )
1144        .unwrap_or_default();
1145        if inside.trim() != "true" {
1146            bail!("not a git repository: {}", root.display());
1147        }
1148        let repo = Self {
1149            root,
1150            style: cfg.style.clone(),
1151            branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1152            state_store: cfg.loop_cfg.state_store,
1153            followups: cfg.loop_cfg.followups,
1154            drafts: cfg.loop_cfg.drafts,
1155            viewer: OnceLock::new(),
1156            checkpoints: Mutex::new(BTreeMap::new()),
1157            writes: WriteStats::default(),
1158        };
1159        repo.self_exclude();
1160        Ok(repo)
1161    }
1162
1163    /// Keep spar's own scratch directories out of the target repo's
1164    /// `git status`.
1165    ///
1166    /// Written to `.git/info/exclude`, never to a tracked `.gitignore`: this is
1167    /// somebody else's repository and spar has no business committing to it.
1168    /// Best effort and silent on failure, because a read-only git directory is
1169    /// not a reason to abandon a run.
1170    fn self_exclude(&self) {
1171        let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1172        let git_dir = git_dir.trim();
1173        if git_dir.is_empty() {
1174            return;
1175        }
1176        let path = Path::new(git_dir).join("info").join("exclude");
1177        let existing = std::fs::read_to_string(&path).unwrap_or_default();
1178
1179        // The marker too. It is written into whichever directory the call ran
1180        // in, which for a shared checkout is the repository root, and an
1181        // untracked file there is one `git add -A` away from being committed.
1182        // The error names its path, which is how a person is meant to find it.
1183        let wanted = [
1184            format!("/{WORKTREE_DIR}/"),
1185            format!("/{STATE_DIR}/"),
1186            format!("{RECOVERY_MARKER_PREFIX}*"),
1187        ];
1188        let missing: Vec<&String> = wanted
1189            .iter()
1190            .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1191            .collect();
1192        if missing.is_empty() {
1193            return;
1194        }
1195
1196        use std::io::Write;
1197        if let Some(parent) = path.parent() {
1198            let _ = std::fs::create_dir_all(parent);
1199        }
1200        let mut block = String::new();
1201        if !existing.is_empty() && !existing.ends_with('\n') {
1202            block.push('\n');
1203        }
1204        block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1205        for line in missing {
1206            block.push_str(line);
1207            block.push('\n');
1208        }
1209        if let Ok(mut file) = std::fs::OpenOptions::new()
1210            .create(true)
1211            .append(true)
1212            .open(&path)
1213        {
1214            let _ = file.write_all(block.as_bytes());
1215        }
1216    }
1217
1218    pub fn root(&self) -> &Path {
1219        &self.root
1220    }
1221
1222    pub(crate) fn write_summary(&self) -> WriteSummary {
1223        WriteSummary {
1224            attempted: self.writes.attempted.load(Ordering::Relaxed),
1225            failed: self.writes.failed.load(Ordering::Relaxed),
1226        }
1227    }
1228
1229    pub(crate) fn record_write<T, E>(
1230        &self,
1231        result: std::result::Result<T, E>,
1232    ) -> std::result::Result<T, E> {
1233        self.record_write_outcome(result.is_err());
1234        result
1235    }
1236
1237    pub(crate) fn record_failed_write<T, E>(
1238        &self,
1239        result: std::result::Result<T, E>,
1240    ) -> std::result::Result<T, E> {
1241        if result.is_err() {
1242            self.record_write_outcome(true);
1243        }
1244        result
1245    }
1246
1247    fn record_write_outcome(&self, failed: bool) {
1248        self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1249        if failed {
1250            self.writes.failed.fetch_add(1, Ordering::Relaxed);
1251        }
1252    }
1253
1254    // -- gates ------------------------------------------------------------
1255
1256    /// Scrub, then verify. A leak here reaches GitHub, so it is a hard error
1257    /// rather than a warning: silent partial compliance is how a style rule
1258    /// erodes over a long run.
1259    pub fn clean(&self, text: &str) -> Result<String> {
1260        let out = style::scrub(text, &self.style);
1261        let bad = style::violations(&out, &self.style);
1262        if !bad.is_empty() {
1263            bail!(
1264                "style gate could not clean text ({}): {}",
1265                bad.join(", "),
1266                style::clip(&out, 300)
1267            );
1268        }
1269        Ok(out)
1270    }
1271
1272    /// Clean, and hold to a length budget. For anything a model wrote.
1273    pub fn clean_body(&self, text: &str) -> Result<String> {
1274        self.clean(&style::body(text, &self.style))
1275    }
1276
1277    /// The same, with an issue's far larger budget and its exemption for code.
1278    pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1279        self.clean(&style::issue_body(text, &self.style))
1280    }
1281
1282    /// The single transform every outbound title goes through.
1283    ///
1284    /// Scrub first, clip second, and never the other way round. Clipping first
1285    /// lets the scrub lengthen the result past the budget (an em dash becomes
1286    /// two characters), so a second pass would clip again and produce a
1287    /// different string. That broke follow-up deduplication silently: the
1288    /// lookup searched for one title while GitHub had stored another, no match
1289    /// was ever found, and a fresh duplicate issue was filed every review
1290    /// round. Doing it in this order makes the transform idempotent, which the
1291    /// tests assert.
1292    pub fn clean_title(&self, text: &str) -> Result<String> {
1293        Ok(style::title(&self.clean(text)?, &self.style))
1294    }
1295
1296    pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1297        let title = self.record_failed_write(self.clean_title(text))?;
1298        if title.trim().is_empty() {
1299            return self.record_failed_write(Err(spar_err!(
1300                "nothing left of the title after cleaning it"
1301            )));
1302        }
1303        Ok(title)
1304    }
1305
1306    pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1307        if self.followups == Followups::Issues {
1308            self.clean_nonempty_title_for_write(text)
1309        } else {
1310            self.clean_title(text)
1311        }
1312    }
1313
1314    // -- git --------------------------------------------------------------
1315
1316    fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1317        ExecOpts::new()
1318            .cwd(cwd.unwrap_or(&self.root))
1319            .check(check)
1320            .timeout_secs(600)
1321    }
1322
1323    pub fn git(&self, args: &[&str]) -> Result<String> {
1324        self.git_at(None, args)
1325    }
1326
1327    pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1328        let argv = git_without_maintenance_argv(args);
1329        proc::run(&argv, &self.git_opts(cwd, true))
1330    }
1331
1332    /// Run a parent-side Git operation without inherited background helpers.
1333    ///
1334    /// Editing calls are untrusted. Once one returns, status, staging, and
1335    /// committing happen in this process, so an inherited fsmonitor or automatic
1336    /// maintenance command must not become a way to execute outside its sandbox.
1337    fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1338        let argv = git_without_automation_argv(args);
1339        proc::run(
1340            &argv,
1341            &self.git_opts(Some(cwd), true).stop_descendants(true),
1342        )
1343    }
1344
1345    fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1346        let argv = git_without_automation_argv(args);
1347        proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1348            .map(|output| output.ok())
1349    }
1350
1351    /// Run git, tolerating failure. Returns whatever landed on stdout.
1352    pub fn git_try(&self, args: &[&str]) -> String {
1353        self.git_try_at(None, args)
1354    }
1355
1356    pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1357        let argv = git_without_maintenance_argv(args);
1358        proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1359    }
1360
1361    /// The base branch the remote actually points at, rather than assuming
1362    /// `main`. Falls back to the configured value when there is no origin.
1363    pub fn default_branch(&self, configured: &str) -> String {
1364        let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1365        match refname.trim().rsplit('/').next() {
1366            Some(name) if !name.is_empty() => name.to_string(),
1367            _ => configured.to_string(),
1368        }
1369    }
1370
1371    // -- branch naming and ownership --------------------------------------
1372    //
1373    // Branch names default to `issue-N`, which is exactly what a person would
1374    // name a branch by hand. Ownership therefore cannot be inferred from the
1375    // name, so every branch spar creates is recorded and cleanup only ever
1376    // touches what is in that record.
1377
1378    pub fn branch_for_issue(&self, issue: i64) -> String {
1379        format!("{}issue-{issue}", self.branch_prefix)
1380    }
1381
1382    pub fn branch_for_pr(&self, number: i64) -> String {
1383        format!("{}pr-{number}", self.branch_prefix)
1384    }
1385
1386    /// One part of a split, numbered from 1 within its parent.
1387    ///
1388    /// Its own namespace rather than `issue-N`, because the parts of a split
1389    /// pull request have no issue of their own and would otherwise collide with
1390    /// the branch of the issue that happens to share the parent's number.
1391    ///
1392    /// The name a part is tried on first. `worktree_for_split` may end up on a
1393    /// suffixed one, because this name is not free forever.
1394    pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1395        format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1396    }
1397
1398    fn ledger_path(&self) -> PathBuf {
1399        self.root.join(STATE_DIR).join("branches.json")
1400    }
1401
1402    pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1403        std::fs::read_to_string(self.ledger_path())
1404            .ok()
1405            .and_then(|text| serde_json::from_str(&text).ok())
1406            .unwrap_or_default()
1407    }
1408
1409    pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1410        let mut data = self.known_branches();
1411        data.insert(
1412            branch.to_string(),
1413            BranchRecord {
1414                kind: kind.to_string(),
1415                number,
1416            },
1417        );
1418        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1419            logdim!("could not record branch {branch}: {e}");
1420        }
1421    }
1422
1423    pub fn forget_branch(&self, branch: &str) {
1424        let mut data = self.known_branches();
1425        if data.remove(branch).is_none() {
1426            return;
1427        }
1428        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1429            logdim!("could not update the branch record: {e}");
1430        }
1431    }
1432
1433    // -- worktrees --------------------------------------------------------
1434
1435    fn worktree_path(&self, name: &str) -> PathBuf {
1436        self.root.join(WORKTREE_DIR).join(name)
1437    }
1438
1439    /// Isolate an issue so a failed run cannot poison the next one's base.
1440    pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1441        let branch = self.branch_for_issue(issue);
1442        let path = self.worktree_path(&format!("issue-{issue}"));
1443
1444        self.refuse_issue_branch_rebuild(issue, base)?;
1445        self.refuse_dirty_worktree(
1446            &path,
1447            &format!("worktree for issue #{issue}"),
1448            Disposable::BuildOutput,
1449        )?;
1450
1451        if !self.branch_deletion_is_safe(&branch)? {
1452            bail!(
1453                "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1454                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1455                 before retrying."
1456            );
1457        }
1458
1459        if !self.remove_worktree_at(&path)? {
1460            bail!(
1461                "the existing worktree for issue #{issue} could not be removed safely. Its \
1462                 branch was kept."
1463            );
1464        }
1465        if !self.delete_branch_if_safe(&branch)? {
1466            bail!(
1467                "the existing branch {branch} changed or remained checked out while its \
1468                 worktree was being rebuilt. It was kept."
1469            );
1470        }
1471
1472        if let Some(parent) = path.parent() {
1473            std::fs::create_dir_all(parent)
1474                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1475        }
1476
1477        let path_str = path.display().to_string();
1478        let remote_start = format!("origin/{base}");
1479        let created = self
1480            .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1481            .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1482
1483        // Recorded on both paths: an unrecorded branch is one cleanup will
1484        // never remove, and the fallback creates a branch just the same.
1485        created.map_err(|e| {
1486            spar_err!(
1487                "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1488                 and does `origin` exist?",
1489                e.last_line()
1490            )
1491        })?;
1492        self.record_branch(&branch, "issue", issue);
1493        Ok((path, branch))
1494    }
1495
1496    /// Refuse to reset the local or remote branch assigned to an issue when it
1497    /// carries work that no pull request preserves.
1498    ///
1499    /// Both linked-worktree mode and shared-checkout mode rebuild the same
1500    /// branch name. Keeping the guard here prevents either path from silently
1501    /// replacing recovery commits left by an earlier run.
1502    pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1503        let branch = self.branch_for_issue(issue);
1504        let base_remote_ref = format!("refs/heads/{base}");
1505        let base_tracking_ref = format!("refs/remotes/origin/{base}");
1506        let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1507        self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1508            .map_err(|e| {
1509                spar_err!(
1510                    "could not refresh origin/{base} before checking issue #{issue}: {}",
1511                    e.last_line()
1512                )
1513            })?;
1514
1515        if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
1516            let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
1517            if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
1518                bail!(
1519                    "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
1520                     pull request accounts for them. Rebuilding it would force push over that \
1521                     work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
1522                     it, or delete it with `git push origin --delete {branch}` if the remote \
1523                     branch is no longer needed."
1524                );
1525            }
1526        }
1527
1528        let local_ref = format!("refs/heads/{branch}");
1529        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1530            let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
1531            let recorded_pr = self
1532                .known_branches()
1533                .get(&branch)
1534                .is_some_and(|record| record.kind == "pr");
1535            let preserved = ahead == 0
1536                || if recorded_pr {
1537                    self.local_branch_is_preserved(&branch)?
1538                } else {
1539                    self.pull_request_holds(&branch, &local_ref, base)
1540                };
1541            if !preserved {
1542                let listed = self
1543                    .commit_lines(&self.root, &local_ref, base)
1544                    .iter()
1545                    .map(|line| format!("  {line}"))
1546                    .collect::<Vec<_>>()
1547                    .join("\n");
1548                bail!(
1549                    "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
1550                     no pull request preserves them. Rebuilding it would delete the only copy.\n\
1551                     {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
1552                     it, or delete it with `git branch -D {branch}` if it is stale."
1553                );
1554            }
1555        }
1556        Ok(())
1557    }
1558
1559    fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
1560        let live_ref = format!("refs/heads/{branch}");
1561        let tracking_ref = format!("refs/remotes/origin/{branch}");
1562        let listed = self
1563            .git(&["ls-remote", "--heads", "origin", &live_ref])
1564            .map_err(|e| {
1565                spar_err!(
1566                    "could not verify whether origin/{branch} still exists: {}",
1567                    e.last_line()
1568                )
1569            })?;
1570
1571        if remote_head_oid(&listed, &live_ref)?.is_some() {
1572            let refspec = format!("+{live_ref}:{tracking_ref}");
1573            self.git(&["fetch", "--no-tags", "origin", &refspec])
1574                .map_err(|e| {
1575                    spar_err!(
1576                        "origin/{branch} exists but its tracking ref could not be refreshed: {}",
1577                        e.last_line()
1578                    )
1579                })?;
1580            if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1581                bail!("origin/{branch} was fetched but its tracking ref is missing");
1582            }
1583            return Ok(Some(tracking_ref));
1584        }
1585
1586        if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1587            return Ok(None);
1588        }
1589        let expected = self
1590            .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
1591            .trim()
1592            .to_string();
1593        self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
1594            .map_err(|e| {
1595                spar_err!(
1596                    "could not discard stale origin/{branch} tracking ref safely: {}",
1597                    e.last_line()
1598                )
1599            })?;
1600        if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1601            bail!(
1602                "origin/{branch} changed while its stale tracking ref was being removed. It was \
1603                 kept."
1604            );
1605        }
1606        Ok(None)
1607    }
1608
1609    /// Whether a pull request from `branch` already holds every commit `refname`
1610    /// has beyond `base`.
1611    ///
1612    /// GitHub serves `refs/pull/N/head` for as long as the repository lives, so
1613    /// commits that reached a pull request outlive the branch they were pushed
1614    /// from. A matching branch name does not establish that on its own: an
1615    /// issue worked twice reuses the name, and the merged pull request from the
1616    /// first round says nothing about where the second round's commits are.
1617    fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
1618        self.prs_for_branch(branch)
1619            .iter()
1620            .any(|pr| self.pr_head_holds(pr.number, refname, base))
1621    }
1622
1623    fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
1624        let head = format!("refs/spar/pr-head/{number}");
1625        let refspec = format!("+refs/pull/{number}/head:{head}");
1626        if self.git(&["fetch", "origin", &refspec]).is_err() {
1627            return false;
1628        }
1629        let held = self.commits_held_by(refname, base, &head);
1630        self.git_try(&["update-ref", "-d", &head]);
1631        held
1632    }
1633
1634    pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
1635        let argv = vec![
1636            "git".to_string(),
1637            "merge-base".to_string(),
1638            "--is-ancestor".to_string(),
1639            older.to_string(),
1640            newer.to_string(),
1641        ];
1642        let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
1643        match out.code {
1644            0 => Ok(true),
1645            1 => Ok(false),
1646            _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
1647        }
1648    }
1649
1650    fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
1651        let head = format!("refs/spar/pr-head/{number}");
1652        let refspec = format!("+refs/pull/{number}/head:{head}");
1653        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1654            spar_err!(
1655                "could not verify the immutable head of PR #{number}: {}",
1656                e.last_line()
1657            )
1658        })?;
1659        let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
1660        self.git_try(&["update-ref", "-d", &head]);
1661        held
1662    }
1663
1664    fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
1665        let text = self.gh(&[
1666            "pr",
1667            "list",
1668            "--head",
1669            branch,
1670            "--state",
1671            "all",
1672            "--json",
1673            "number,url,title",
1674        ])?;
1675        serde_json::from_str(text.trim())
1676            .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
1677    }
1678
1679    fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
1680        let branch_ref = format!("refs/heads/{branch}");
1681        if record.kind == "pr" {
1682            return self.pr_head_contains_checked(record.number, &branch_ref);
1683        }
1684        let prs = self.branch_prs_checked(branch)?;
1685        if prs.is_empty() {
1686            return Ok(false);
1687        }
1688        for pr in prs {
1689            if self.pr_head_contains_checked(pr.number, &branch_ref)? {
1690                return Ok(true);
1691            }
1692        }
1693        Ok(false)
1694    }
1695
1696    fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
1697        let local_ref = format!("refs/heads/{branch}");
1698        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1699            return Ok(true);
1700        }
1701        let oid = self
1702            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1703            .trim()
1704            .to_string();
1705        let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
1706        if !durable_tip {
1707            let remote_ref = format!("refs/heads/{branch}");
1708            let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
1709            durable_tip = remote.lines().any(|line| {
1710                line.split_whitespace()
1711                    .next()
1712                    .is_some_and(|remote_oid| remote_oid == oid)
1713            });
1714        }
1715        if !durable_tip {
1716            if let Some(record) = self.known_branches().get(branch) {
1717                durable_tip = self.branch_is_preserved_checked(branch, record)?;
1718            }
1719        }
1720        if !durable_tip {
1721            return Ok(false);
1722        }
1723        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1724    }
1725
1726    /// Delete a branch only if its exact current tip and reflog are still safe.
1727    /// The expected old value makes a concurrent ref update fail instead of
1728    /// deleting work that appeared after the preservation check.
1729    fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
1730        let local_ref = format!("refs/heads/{branch}");
1731        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1732            return Ok(true);
1733        }
1734        let expected = self
1735            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1736            .trim()
1737            .to_string();
1738        if !self.branch_deletion_is_safe(branch)? {
1739            return Ok(false);
1740        }
1741        let checked_out = self
1742            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
1743            .lines()
1744            .any(|line| line == format!("branch {local_ref}"));
1745        if checked_out {
1746            return Ok(false);
1747        }
1748        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1749        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1750    }
1751
1752    fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
1753        let local_ref = review_ref(number);
1754        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1755            return Ok(true);
1756        }
1757        let oid = self
1758            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1759            .trim()
1760            .to_string();
1761        if !self.pr_head_contains_checked(number, &local_ref)? {
1762            return Ok(false);
1763        }
1764        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1765    }
1766
1767    fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
1768        let local_ref = review_ref(number);
1769        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1770            return Ok(true);
1771        }
1772        let expected = self
1773            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1774            .trim()
1775            .to_string();
1776        if !self.review_ref_deletion_is_safe(number)? {
1777            return Ok(false);
1778        }
1779        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1780        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1781    }
1782
1783    /// Whether `other` already contains every commit `branch` has beyond
1784    /// `base`. False when either ref fails to resolve, so a ref that is not
1785    /// there cannot vouch for anything.
1786    pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
1787        let range = format!("{}..{branch}", self.base_ref(&self.root, base));
1788        self.git_try(&["rev-list", "--count", &range, "--not", other])
1789            .trim()
1790            == "0"
1791    }
1792
1793    pub fn worktree_remove(&self, issue: i64) -> bool {
1794        let path = self.worktree_path(&format!("issue-{issue}"));
1795        match self.remove_worktree_at(&path) {
1796            Ok(removed) => removed,
1797            Err(error) => {
1798                logdim!(
1799                    "kept {} because removal did not reach a confirmed quiet point: {}",
1800                    path.display(),
1801                    error.last_line()
1802                );
1803                false
1804            }
1805        }
1806    }
1807
1808    /// Verify both the common Git directory and the worktree top level.
1809    ///
1810    /// A stale worktree entry is not ownership proof. An unrelated repository
1811    /// can later occupy the same path and must survive cleanup.
1812    fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
1813        let wanted = std::fs::canonicalize(path)
1814            .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
1815        // Every SPAR worktree path is built from the canonical repository root.
1816        // A different canonical path therefore means the final component or
1817        // one of its parents is a symlink. Passing that alias to `git worktree
1818        // remove` can delete the worktree at its real target.
1819        if wanted != path {
1820            return Ok(false);
1821        }
1822        let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
1823            let raw = PathBuf::from(value.trim());
1824            let joined = if raw.is_absolute() {
1825                raw
1826            } else {
1827                cwd.join(raw)
1828            };
1829            std::fs::canonicalize(&joined)
1830                .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
1831        };
1832        let expected =
1833            self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
1834        let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
1835        let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
1836        let expected = resolve(&self.root, &expected)?;
1837        let actual = resolve(path, &actual)?;
1838        let top = resolve(path, &top)?;
1839        Ok(expected == actual && top == wanted)
1840    }
1841
1842    /// Remove only a worktree that belongs to this repository.
1843    ///
1844    /// The path sits under a predictable directory, but that does not establish
1845    /// ownership. A clean independent repository at the same path must survive
1846    /// even when `git worktree remove` rejects it.
1847    fn remove_worktree_at_with_force(
1848        &self,
1849        path: &Path,
1850        force: bool,
1851        disposable: Disposable,
1852    ) -> Result<bool> {
1853        let existed = path.exists();
1854        if path.exists() {
1855            match self.worktree_belongs_to_repo(path) {
1856                Ok(true) => {}
1857                Ok(false) => {
1858                    logdim!(
1859                        "kept {} because it is not a worktree owned by this repository",
1860                        path.display()
1861                    );
1862                    return Ok(false);
1863                }
1864                Err(e) => {
1865                    logdim!(
1866                        "kept {} because its worktree ownership could not be verified: {}",
1867                        path.display(),
1868                        e.last_line()
1869                    );
1870                    return Ok(false);
1871                }
1872            }
1873            if !force {
1874                match self.has_recoverable_work(path, disposable) {
1875                    Ok(true) => {
1876                        logdim!(
1877                            "kept {} because it contains recoverable files or repository state",
1878                            path.display()
1879                        );
1880                        return Ok(false);
1881                    }
1882                    Err(e) => {
1883                        logdim!(
1884                            "kept {} because its recoverable state could not be checked: {}",
1885                            path.display(),
1886                            e.last_line()
1887                        );
1888                        return Ok(false);
1889                    }
1890                    Ok(false) => {}
1891                }
1892            }
1893        }
1894        // Git refuses to remove a worktree holding any untracked file, which is
1895        // the same question SPAR has just answered in more detail: under
1896        // `ReviewOutput` the checks above have already established that nothing
1897        // there is worth keeping. `--force` here is that answer being carried
1898        // out, not the check being skipped, which is what `force` means.
1899        let path_str = path.display().to_string();
1900        let command_ok = if force || disposable == Disposable::ReviewOutput {
1901            self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
1902        } else {
1903            self.git_try_without_automation(&["worktree", "remove", &path_str])?
1904        };
1905        Ok((command_ok || !existed) && !path.exists())
1906    }
1907
1908    fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
1909        self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
1910    }
1911
1912    /// Remove a review checkout that is about to be rebuilt from origin.
1913    fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
1914        self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
1915    }
1916
1917    /// Force removal is reserved for the explicit `clean --all` path.
1918    fn remove_worktree_at_force(&self, path: &Path) -> bool {
1919        match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
1920            Ok(removed) => removed,
1921            Err(error) => {
1922                logdim!(
1923                    "kept {} because removal did not reach a confirmed quiet point: {}",
1924                    path.display(),
1925                    error.last_line()
1926                );
1927                false
1928            }
1929        }
1930    }
1931
1932    /// Remove a worktree only after a caller has verified it is unchanged.
1933    ///
1934    /// There is deliberately no force fallback, so Git can still refuse a
1935    /// removal if tracked or non-ignored work appears after the final check.
1936    fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
1937        if path.exists() && !self.worktree_belongs_to_repo(path)? {
1938            bail!(
1939                "{} is not a worktree owned by this repository, so it was kept",
1940                path.display()
1941            );
1942        }
1943        if path.exists() && self.has_recoverable_work(path, disposable)? {
1944            bail!(
1945                "the verified worktree at {} contains recoverable files or repository state. It \
1946                 was kept.",
1947                path.display()
1948            );
1949        }
1950        let path_str = path.display().to_string();
1951        self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
1952            .map_err(|e| {
1953                e.with_message(format!(
1954                    "could not remove the verified worktree at {}: {}. It was kept.",
1955                    path.display(),
1956                    e.last_line()
1957                ))
1958            })?;
1959        Ok(!path.exists())
1960    }
1961
1962    fn refuse_dirty_worktree(
1963        &self,
1964        path: &Path,
1965        label: &str,
1966        disposable: Disposable,
1967    ) -> Result<()> {
1968        if !path.is_dir() {
1969            return Ok(());
1970        }
1971        let has_files = std::fs::read_dir(path)
1972            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
1973            .next()
1974            .is_some();
1975        let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
1976            spar_err!(
1977                "could not verify whether the existing {label} at {} belongs to this repository, \
1978                 so it was kept: {}",
1979                path.display(),
1980                e.last_line()
1981            )
1982        })?;
1983        if !owned {
1984            if has_files {
1985                bail!(
1986                    "the existing {label} at {} is not a worktree owned by \
1987                     this repository. Refusing to remove it.",
1988                    path.display()
1989                );
1990            }
1991            return Ok(());
1992        }
1993        if !path.join(".git").exists() {
1994            if has_files {
1995                bail!(
1996                    "the existing {label} at {} is not a readable Git worktree and is not empty. \
1997                     Refusing to remove it.",
1998                    path.display()
1999                );
2000            }
2001            return Ok(());
2002        }
2003        let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2004            spar_err!(
2005                "could not verify whether the existing {label} at {} is clean, so it was kept: \
2006                 {}",
2007                path.display(),
2008                e.last_line()
2009            )
2010        })?;
2011        if dirty {
2012            bail!(
2013                "the existing {label} contains uncommitted changes or ignored files at {}. \
2014                 Rebuilding it would delete those files.\nCommit or recover them before running this \
2015                 command again, or use `spar clean --all` if they are not needed.",
2016                path.display()
2017            );
2018        }
2019        Ok(())
2020    }
2021
2022    /// Check an existing PR branch out into an isolated worktree.
2023    pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
2024        let head = pr.head_ref_name.clone();
2025        if head.trim().is_empty() {
2026            bail!("PR #{} has no head branch to check out", pr.number);
2027        }
2028        let path = self.worktree_path(&format!("pr-{}", pr.number));
2029        let local = self.branch_for_pr(pr.number);
2030
2031        self.git(&["fetch", "origin", &head]).map_err(|e| {
2032            spar_err!(
2033                "could not fetch the branch behind PR #{}: {}",
2034                pr.number,
2035                e.last_line()
2036            )
2037        })?;
2038        let start = format!("origin/{head}");
2039        let start_ref = format!("refs/remotes/origin/{head}");
2040        let local_ref = format!("refs/heads/{local}");
2041        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2042            let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2043            if unpushed > 0 {
2044                bail!(
2045                    "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2046                     on {start}. Rebuilding it would delete their branch.\nInspect the worktree at \
2047                     {} and push or recover those commits before running this command again.",
2048                    pr.number,
2049                    path.display()
2050                );
2051            }
2052        }
2053        self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2054        self.refuse_dirty_worktree(
2055            &path,
2056            &format!("worktree for PR #{}", pr.number),
2057            Disposable::BuildOutput,
2058        )?;
2059        if !self.branch_deletion_is_safe(&local)? {
2060            bail!(
2061                "the existing branch {local} has a tip or reflog-only commit that no surviving \
2062                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2063                 before retrying."
2064            );
2065        }
2066        if !self.remove_worktree_at(&path)? {
2067            bail!(
2068                "the existing worktree for PR #{} could not be removed safely. Its branch was \
2069                 kept.",
2070                pr.number
2071            );
2072        }
2073        if !self.delete_branch_if_safe(&local)? {
2074            bail!(
2075                "the existing branch {local} changed or remained checked out while the PR \
2076                 worktree was being rebuilt. It was kept."
2077            );
2078        }
2079
2080        let path_str = path.display().to_string();
2081        self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2082        self.record_branch(&local, "pr", pr.number);
2083        Ok((path, head))
2084    }
2085
2086    /// A retry needs a fresh checkout, not permission to delete ignored data.
2087    /// Keep that data and its branch when all commits are already published.
2088    fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2089        if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2090            return Ok(());
2091        }
2092        let current =
2093            self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2094        if current.trim() != branch
2095            || self.has_uncommitted_changes(path)?
2096            || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2097        {
2098            return Ok(());
2099        }
2100        static NEXT: AtomicUsize = AtomicUsize::new(0);
2101        let (name, retained) = loop {
2102            let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2103            let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2104            let retained = self.worktree_path(&name);
2105            if !retained
2106                .try_exists()
2107                .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2108                && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2109            {
2110                break (name, retained);
2111            }
2112        };
2113        // Neither command overwrites an existing destination. The branch rename
2114        // preserves its reflog, and the worktree move preserves every file.
2115        self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2116        if let Err(error) = self.git_at_without_automation(
2117            &self.root,
2118            &[
2119                "worktree",
2120                "move",
2121                &path.display().to_string(),
2122                &retained.display().to_string(),
2123            ],
2124        ) {
2125            // A locked worktree or one with submodules cannot always be moved.
2126            // Restore the branch name so a failed move does not strand a retry.
2127            let rollback =
2128                self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2129            let detail = rollback
2130                .err()
2131                .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2132                .unwrap_or_default();
2133            bail!(
2134                "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2135                retained.display(),
2136                error.last_line()
2137            );
2138        }
2139        logdim!(
2140            "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2141            retained.display()
2142        );
2143        Ok(())
2144    }
2145
2146    /// Check a pull request's head out read only, detached, with no branch.
2147    ///
2148    /// Fetches `refs/pull/N/head`, which GitHub serves for every pull request
2149    /// including one from a fork whose branch is not in this repository at all.
2150    /// That is what makes reviewing an outside contribution possible when
2151    /// pushing to it is not.
2152    ///
2153    /// Detached on purpose. Review only mode has nothing to push, and a branch
2154    /// would only invite something to try.
2155    pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2156        let path = self.worktree_path(&format!("review-{number}"));
2157        let local_ref = review_ref(number);
2158        let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2159
2160        self.refuse_review_worktree_changes(number)?;
2161
2162        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2163            spar_err!(
2164                "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2165                 every pull request, so this usually means the number is wrong or `origin` does \
2166                 not point at the repository the PR is on.",
2167                e.last_line()
2168            )
2169        })?;
2170
2171        if let Some(parent) = path.parent() {
2172            std::fs::create_dir_all(parent)
2173                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2174        }
2175        if !self.remove_review_worktree_at(&path)? {
2176            bail!(
2177                "the existing review worktree for PR #{number} could not be removed safely. Its \
2178                 reference was kept."
2179            );
2180        }
2181        let path_str = path.display().to_string();
2182        self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2183        Ok(path)
2184    }
2185
2186    fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2187        let path = self.worktree_path(&format!("review-{number}"));
2188        if !path.is_dir() {
2189            return Ok(());
2190        }
2191        let local_ref = review_ref(number);
2192        if !self.worktree_belongs_to_repo(&path)? {
2193            return Ok(());
2194        }
2195        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2196            bail!(
2197                "the existing review worktree for PR #{number} has no recorded head at \
2198                 {local_ref}. Refusing to rebuild {}.",
2199                path.display()
2200            );
2201        }
2202        let worktree_head = self.head_oid_checked(&path)?;
2203        let recorded_head = self
2204            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2205            .trim()
2206            .to_string();
2207        if worktree_head != recorded_head {
2208            bail!(
2209                "the existing review worktree for PR #{number} has a local commit that is not on \
2210                 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2211                 {} before retrying.",
2212                path.display()
2213            );
2214        }
2215        self.report_recovery_markers(&path);
2216        self.refuse_dirty_worktree(
2217            &path,
2218            &format!("review worktree for PR #{number}"),
2219            Disposable::ReviewOutput,
2220        )?;
2221        Ok(())
2222    }
2223
2224    /// Read out what an earlier run refused to explain away, before rebuilding
2225    /// over it.
2226    ///
2227    /// The marker is the only record of why a call's answer was thrown out, and
2228    /// a review checkout is rebuilt on the next run. Saying it here is what
2229    /// keeps the file from being the thing that quietly blocks the command
2230    /// instead.
2231    fn report_recovery_markers(&self, path: &Path) {
2232        let Ok(entries) = std::fs::read_dir(path) else {
2233            return;
2234        };
2235        for entry in entries.flatten() {
2236            let name = entry.file_name();
2237            let Some(name) = name.to_str() else { continue };
2238            if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2239                continue;
2240            }
2241            match std::fs::read_to_string(entry.path()) {
2242                Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2243                Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2244            }
2245        }
2246    }
2247
2248    /// A worktree for one part of a split, on a new branch off `start`.
2249    ///
2250    /// `start` is the base branch for independent parts and the previous part's
2251    /// branch for stacked ones, which is the only difference between the two
2252    /// shapes at this level.
2253    ///
2254    /// The branch is whatever name was free, which is why it is returned rather
2255    /// than derived by the caller. Splitting the same pull request a second
2256    /// time would otherwise target the branch behind the first run's pull
2257    /// request. Split pushes are create-only and would refuse that target, but
2258    /// a repeated split still needs distinct branches rather than a name that
2259    /// can never be created.
2260    pub fn worktree_for_split(
2261        &self,
2262        parent: i64,
2263        index: usize,
2264        start: &str,
2265    ) -> Result<(PathBuf, String)> {
2266        let slot = self.free_split_slot(parent, index)?;
2267        let branch = format!("{}{slot}", self.branch_prefix);
2268        let path = self.worktree_path(&slot);
2269
2270        if let Some(dir) = path.parent() {
2271            std::fs::create_dir_all(dir)
2272                .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2273        }
2274        self.refuse_dirty_worktree(
2275            &path,
2276            &format!("worktree for part {index} of PR #{parent}"),
2277            Disposable::BuildOutput,
2278        )?;
2279        // The name is free, so there is no branch to delete. A directory can
2280        // still be in the way, left by a worktree that was pruned from git's
2281        // records without being removed from disk.
2282        if !self.remove_worktree_at(&path)? {
2283            bail!(
2284                "the existing worktree for part {index} of PR #{parent} could not be removed \
2285                 safely. No branch was created."
2286            );
2287        }
2288
2289        let path_str = path.display().to_string();
2290        self.git(&["worktree", "add", "-b", &branch, &path_str, start])
2291            .map_err(|e| {
2292                spar_err!(
2293                    "could not create a worktree for part {index} of #{parent}. {}",
2294                    e.last_line()
2295                )
2296            })?;
2297        // Recorded before anything else can fail. An unrecorded branch is one
2298        // `prune_branches` will never remove.
2299        self.record_branch(&branch, "split", parent);
2300        Ok((path, branch))
2301    }
2302
2303    /// The first part branch nothing is already sitting on.
2304    ///
2305    /// Origin as well as local, because a part's branch outlives the local one:
2306    /// a second split of the same pull request finds its own earlier branches
2307    /// deleted here but alive on origin, where the pull requests that reviewed
2308    /// them still point at them.
2309    fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2310        for attempt in 1..=SPLIT_SLOTS {
2311            let slot = split_slot(parent, index, attempt);
2312            let branch = format!("{}{slot}", self.branch_prefix);
2313            self.git_try(&["fetch", "origin", &branch]);
2314            if !self.rev_exists(&self.root, &branch)
2315                && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2316            {
2317                return Ok(slot);
2318            }
2319        }
2320        bail!(
2321            "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2322             names are all taken. Inspect the existing branches and child pull requests. Finish \
2323             recording the earlier split, or remove every retained local worktree and branch, \
2324             child pull request, and remote split branch before starting over.",
2325            self.branch_for_split(parent, index)
2326        )
2327    }
2328
2329    /// Whether a previous attempt pushed any branch for this split.
2330    ///
2331    /// The parent comment is the normal retry marker. A branch is the fallback
2332    /// when that comment or the pull request creation failed after the push.
2333    /// Reading origin directly makes the guard survive a fresh clone.
2334    pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2335        let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2336        Ok(!self
2337            .git(&["ls-remote", "--heads", "origin", &pattern])?
2338            .trim()
2339            .is_empty())
2340    }
2341
2342    /// Throw one part away: its worktree, its branch, and its record.
2343    ///
2344    /// For a part that would not stand on its own. Nothing has been pushed at
2345    /// that point, so this leaves no trace anywhere but the log. Takes what
2346    /// `worktree_for_split` returned, since the name it settled on is not
2347    /// derivable from the parent and the index.
2348    pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2349        match self.branch_deletion_is_safe(branch) {
2350            Ok(true) => {}
2351            Ok(false) => {
2352                logdim!(
2353                    "kept {branch} and {} because no surviving ref preserves its tip",
2354                    dir.display()
2355                );
2356                return;
2357            }
2358            Err(error) => {
2359                logdim!(
2360                    "kept {branch} and {} because preservation could not be verified: {}",
2361                    dir.display(),
2362                    error.last_line()
2363                );
2364                return;
2365            }
2366        }
2367        match self.remove_worktree_at(dir) {
2368            Ok(true) => match self.delete_branch_if_safe(branch) {
2369                Ok(true) => self.forget_branch(branch),
2370                Ok(false) => {
2371                    logdim!("kept {branch} because its tip or reflog changed before deletion")
2372                }
2373                Err(error) => logdim!(
2374                    "kept {branch} because deletion safety could not be rechecked: {}",
2375                    error.last_line()
2376                ),
2377            },
2378            Ok(false) => {}
2379            Err(error) => logdim!(
2380                "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
2381                dir.display(),
2382                error.last_line()
2383            ),
2384        }
2385    }
2386
2387    /// Discard one exact mechanical slice that the split workflow just made.
2388    ///
2389    /// Unlike ordinary release, this intentionally removes an unpushed commit.
2390    /// The caller supplies the exact disposable tip, and every file, worktree,
2391    /// ownership, and ref check must still match before anything is removed.
2392    pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
2393        let record = self.known_branches().get(branch).cloned();
2394        if record.is_none_or(|record| record.kind != "split") {
2395            logdim!("kept {branch} because no split branch record proves ownership");
2396            return false;
2397        }
2398        let local_ref = format!("refs/heads/{branch}");
2399        let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
2400            Ok(value) => value.trim().to_string(),
2401            Err(error) => {
2402                logdim!(
2403                    "kept {branch} because its tip could not be checked: {}",
2404                    error.last_line()
2405                );
2406                return false;
2407            }
2408        };
2409        if expected != disposable_head {
2410            logdim!("kept {branch} because it moved beyond the disposable slice");
2411            return false;
2412        }
2413        match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
2414            Ok(true) => {}
2415            Ok(false) => {
2416                logdim!(
2417                    "kept {branch} because its reflog contains work outside the disposable slice"
2418                );
2419                return false;
2420            }
2421            Err(error) => {
2422                logdim!(
2423                    "kept {branch} because its reflog could not be checked: {}",
2424                    error.last_line()
2425                );
2426                return false;
2427            }
2428        }
2429        match self.head_oid_checked(dir) {
2430            Ok(head) if head == disposable_head => {}
2431            Ok(_) => {
2432                logdim!(
2433                    "kept {branch} and {} because the worktree moved beyond the disposable slice",
2434                    dir.display()
2435                );
2436                return false;
2437            }
2438            Err(error) => {
2439                logdim!(
2440                    "kept {branch} and {} because its head could not be checked: {}",
2441                    dir.display(),
2442                    error.last_line()
2443                );
2444                return false;
2445            }
2446        }
2447        match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
2448            Ok(true) => {}
2449            Ok(false) => return false,
2450            Err(error) => {
2451                logdim!(
2452                    "kept {branch} and {} because the disposable slice could not be verified: {}",
2453                    dir.display(),
2454                    error.last_line()
2455                );
2456                return false;
2457            }
2458        }
2459        if let Err(error) =
2460            self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
2461        {
2462            logdim!(
2463                "kept {branch} because its exact disposable tip could not be deleted: {}",
2464                error.last_line()
2465            );
2466            return false;
2467        }
2468        match self.exact_ref_exists_checked(&self.root, &local_ref) {
2469            Ok(false) => {
2470                self.forget_branch(branch);
2471                true
2472            }
2473            Ok(true) => {
2474                logdim!("kept {branch} because its ref still exists after deletion");
2475                false
2476            }
2477            Err(error) => {
2478                logdim!(
2479                    "kept the branch record for {branch} because deletion could not be verified: {}",
2480                    error.last_line()
2481                );
2482                false
2483            }
2484        }
2485    }
2486
2487    pub fn release_review_worktree(&self, number: i64) {
2488        let path = self.worktree_path(&format!("review-{number}"));
2489        match self.review_ref_deletion_is_safe(number) {
2490            Ok(true) => {}
2491            Ok(false) => {
2492                logdim!(
2493                    "kept {} because no surviving ref preserves its review history",
2494                    path.display()
2495                );
2496                return;
2497            }
2498            Err(error) => {
2499                logdim!(
2500                    "kept {} because review history could not be verified: {}",
2501                    path.display(),
2502                    error.last_line()
2503                );
2504                return;
2505            }
2506        }
2507        match self.remove_worktree_at(&path) {
2508            Ok(true) => match self.delete_review_ref_if_safe(number) {
2509                Ok(true) => {}
2510                Ok(false) => logdim!(
2511                    "kept {} because its review history changed before deletion",
2512                    review_ref(number)
2513                ),
2514                Err(error) => logdim!(
2515                    "kept {} because deletion safety could not be rechecked: {}",
2516                    review_ref(number),
2517                    error.last_line()
2518                ),
2519            },
2520            Ok(false) => {}
2521            Err(error) => logdim!(
2522                "kept {} because removal did not reach a confirmed quiet point: {}",
2523                path.display(),
2524                error.last_line()
2525            ),
2526        }
2527    }
2528
2529    /// Release a read-only review checkout only when every observed part of
2530    /// its Git state still matches the checkpoint captured before the calls.
2531    pub(crate) fn release_review_worktree_checked(
2532        &self,
2533        number: i64,
2534        checkpoint: &WorktreeCheckpoint,
2535    ) -> Result<()> {
2536        let path = self.worktree_path(&format!("review-{number}"));
2537        self.require_unchanged_worktree(
2538            &path,
2539            checkpoint,
2540            &format!("review worktree for PR #{number}"),
2541        )?;
2542        if !self.review_ref_deletion_is_safe(number)? {
2543            bail!(
2544                "the review reference for PR #{number} has reflog-only recovery history. The \
2545                 worktree and reference were kept."
2546            );
2547        }
2548        if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
2549            bail!(
2550                "the verified review worktree at {} could not be removed, so its reference was \
2551                 kept",
2552                path.display()
2553            );
2554        }
2555        if !self.delete_review_ref_if_safe(number)? {
2556            bail!(
2557                "the review reference for PR #{number} changed before deletion. The reference was \
2558                 kept."
2559            );
2560        }
2561        Ok(())
2562    }
2563
2564    pub fn release_pr_worktree(&self, number: i64) -> bool {
2565        let path = self.worktree_path(&format!("pr-{number}"));
2566        let local = self.branch_for_pr(number);
2567        match self.branch_deletion_is_safe(&local) {
2568            Ok(true) => {}
2569            Ok(false) => {
2570                logdim!(
2571                    "kept {local} and {} because no surviving ref preserves its tip",
2572                    path.display()
2573                );
2574                return false;
2575            }
2576            Err(error) => {
2577                logdim!(
2578                    "kept {local} and {} because preservation could not be verified: {}",
2579                    path.display(),
2580                    error.last_line()
2581                );
2582                return false;
2583            }
2584        }
2585        match self.remove_worktree_at(&path) {
2586            Ok(true) => match self.delete_branch_if_safe(&local) {
2587                Ok(true) => {
2588                    self.forget_branch(&local);
2589                    true
2590                }
2591                Ok(false) => {
2592                    logdim!("kept {local} because its tip or reflog changed before deletion");
2593                    false
2594                }
2595                Err(error) => {
2596                    logdim!(
2597                        "kept {local} because deletion safety could not be rechecked: {}",
2598                        error.last_line()
2599                    );
2600                    false
2601                }
2602            },
2603            Ok(false) => false,
2604            Err(error) => {
2605                logdim!(
2606                    "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
2607                    path.display(),
2608                    error.last_line()
2609                );
2610                false
2611            }
2612        }
2613    }
2614
2615    // -- branch state -----------------------------------------------------
2616
2617    /// What to diff against: the remote tracking branch when it resolves, the
2618    /// local branch when it does not.
2619    ///
2620    /// This is not a nicety. Every "did the agent do anything" check hangs off
2621    /// this ref, and `git log` against a ref that does not exist fails silently
2622    /// and reads as "no commits". A checkout whose `origin/main` was never
2623    /// fetched would report every implementation as abandoned and throw the
2624    /// work away.
2625    pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
2626        let remote = format!("origin/{base}");
2627        if self.rev_exists(cwd, &remote) {
2628            return remote;
2629        }
2630        if self.rev_exists(cwd, base) {
2631            logdim!("origin/{base} does not resolve, comparing against local {base}");
2632            return base.to_string();
2633        }
2634        logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
2635        remote
2636    }
2637
2638    fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
2639        let spec = format!("{refname}^{{commit}}");
2640        !self
2641            .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
2642            .trim()
2643            .is_empty()
2644    }
2645
2646    pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
2647        let range = format!("{}..HEAD", self.base_ref(cwd, base));
2648        !self
2649            .git_try_at(Some(cwd), &["log", &range, "--oneline"])
2650            .trim()
2651            .is_empty()
2652    }
2653
2654    fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
2655        let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
2656        Ok(found.lines().any(|line| line.trim() == refname))
2657    }
2658
2659    fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
2660        let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
2661        count.trim().parse::<usize>().map_err(|e| {
2662            spar_err!(
2663                "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
2664                count.trim()
2665            )
2666        })
2667    }
2668
2669    pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
2670        let remote = format!("refs/remotes/origin/{base}");
2671        if self.exact_ref_exists_checked(cwd, &remote)? {
2672            return Ok(remote);
2673        }
2674        let local = format!("refs/heads/{base}");
2675        if self.exact_ref_exists_checked(cwd, &local)? {
2676            return Ok(local);
2677        }
2678        bail!("neither origin/{base} nor local branch {base} resolves")
2679    }
2680
2681    pub(crate) fn commit_count_checked(
2682        &self,
2683        cwd: &Path,
2684        refname: &str,
2685        base: &str,
2686    ) -> Result<usize> {
2687        let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
2688        let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
2689        count.trim().parse::<usize>().map_err(|e| {
2690            spar_err!(
2691                "git returned an invalid commit count for {range}: {:?} ({e})",
2692                count.trim()
2693            )
2694        })
2695    }
2696
2697    pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
2698        Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
2699    }
2700
2701    pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
2702        let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
2703        let head = head.trim().to_string();
2704        if head.is_empty() {
2705            bail!("git returned an empty HEAD for {}", cwd.display());
2706        }
2707        Ok(head)
2708    }
2709
2710    /// Whether a recorded SPAR branch's exact tip is retained by a pull request.
2711    ///
2712    /// Pull request head refs remain available after close or merge, so this is
2713    /// stronger than requiring an open pull request or a live remote branch.
2714    pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
2715        let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
2716        self.local_branch_is_preserved(branch.trim())
2717    }
2718
2719    /// Whether a recorded local branch's exact tip is retained by its pull
2720    /// request head, regardless of which branch is currently checked out.
2721    pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
2722        let known = self.known_branches();
2723        let Some(record) = known.get(branch) else {
2724            return Ok(false);
2725        };
2726        self.branch_is_preserved_checked(branch, record)
2727    }
2728
2729    /// Whether tracked, staged, or non-ignored untracked files are uncommitted.
2730    pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
2731        has_uncommitted_work(cwd)
2732    }
2733
2734    /// Whether removing a worktree would delete any local file Git does not
2735    /// reproduce from its commits, including ignored untracked files.
2736    fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
2737        repository_has_recoverable_work_with(cwd, true, disposable)
2738    }
2739
2740    /// Record ignored artifacts that existed before an editing call.
2741    pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
2742        let attributes = attribute_state(cwd)?;
2743        Ok(WorktreeBaseline {
2744            attributes,
2745            ignored_untracked: ignored_untracked_state(cwd)?,
2746            git_state: safe_git_state(cwd)?,
2747        })
2748    }
2749
2750    /// Capture the Git state of a checkout intended to remain read only while
2751    /// external commands inspect it.
2752    pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
2753        let attributes = attribute_state(cwd)?;
2754        Ok(WorktreeCheckpoint {
2755            path: std::fs::canonicalize(cwd)
2756                .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
2757            attributes,
2758            git_state: safe_git_state(cwd)?,
2759            ignored_untracked: ignored_untracked_state(cwd)?,
2760        })
2761    }
2762
2763    /// Require a read-only checkout to match a previously captured checkpoint.
2764    /// Any probe failure is an error because deletion cannot be proven safe.
2765    pub(crate) fn require_unchanged_worktree(
2766        &self,
2767        cwd: &Path,
2768        checkpoint: &WorktreeCheckpoint,
2769        label: &str,
2770    ) -> Result<()> {
2771        let resolved = std::fs::canonicalize(cwd).map_err(|e| {
2772            crate::error::SparError::uncertain_write(format!(
2773                "could not resolve the {label} at {} after inspection: {e}. It was kept.",
2774                cwd.display()
2775            ))
2776        })?;
2777        if resolved != checkpoint.path {
2778            return Err(uncertain_worktree_change(
2779                cwd,
2780                format!(
2781                    "the {label} moved from {} to {} during inspection. It was kept.",
2782                    checkpoint.path.display(),
2783                    resolved.display()
2784                ),
2785            ));
2786        }
2787        let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
2788            uncertain_worktree_change(
2789                cwd,
2790                format!(
2791                    "could not verify attribute files in the {label} at {}: {}. It was kept.",
2792                    cwd.display(),
2793                    e.last_line()
2794                ),
2795            )
2796        })?;
2797        if attributes != checkpoint.attributes {
2798            return Err(uncertain_worktree_change(
2799                cwd,
2800                format!(
2801                    "attribute files in the {label} at {} changed during inspection. It was \
2802                     kept for recovery.",
2803                    cwd.display()
2804                ),
2805            ));
2806        }
2807        let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
2808            uncertain_worktree_change(
2809                cwd,
2810                format!(
2811                    "could not verify the Git state of the {label} at {}: {}. It was kept.",
2812                    cwd.display(),
2813                    e.last_line()
2814                ),
2815            )
2816        })?;
2817        let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
2818            uncertain_worktree_change(
2819                cwd,
2820                format!(
2821                    "could not verify untracked files in the {label} at {}: {}. It was kept.",
2822                    cwd.display(),
2823                    e.last_line()
2824                ),
2825            )
2826        })?;
2827        let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
2828        // Said even when the inspection is accepted, for the same reason it is
2829        // said on an agent call: a path the project ignores is out of scope,
2830        // not unnoticed.
2831        note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
2832        let tracked = checkpoint.git_state.describe_difference(&git_state);
2833        let untracked = (!leavings.condemning.is_empty()).then(|| {
2834            format!(
2835                "untracked file(s) changed: {}",
2836                list_paths(&leavings.condemning)
2837            )
2838        });
2839        let Some(reason) = join_reasons(tracked, untracked) else {
2840            return Ok(());
2841        };
2842        Err(uncertain_worktree_change(
2843            cwd,
2844            format!(
2845                "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
2846                 for recovery.",
2847                cwd.display()
2848            ),
2849        ))
2850    }
2851
2852    /// Accept project-ignored files left by a successful editing call.
2853    /// Ordinary untracked files must already be represented by the commit.
2854    /// Acceptance does not authorize cleanup of the ignored files.
2855    pub(crate) fn refuse_new_ignored_files(
2856        &self,
2857        cwd: &Path,
2858        baseline: &WorktreeBaseline,
2859    ) -> Result<()> {
2860        self.allow_ignored_files(cwd, baseline).map(drop)
2861    }
2862
2863    /// Preserve unexpected output when the editing call did not finish.
2864    pub(crate) fn refuse_failed_edit_leavings(
2865        &self,
2866        cwd: &Path,
2867        baseline: &WorktreeBaseline,
2868    ) -> Result<()> {
2869        self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
2870            .map(drop)
2871    }
2872
2873    /// Reject ordinary untracked changes after a read-only call.
2874    pub(crate) fn refuse_read_only_leavings(
2875        &self,
2876        cwd: &Path,
2877        baseline: &WorktreeBaseline,
2878    ) -> Result<()> {
2879        self.refuse_changed_attributes(cwd, baseline)?;
2880        let after = ignored_untracked_state(cwd).map_err(|e| {
2881            uncertain_worktree_change(
2882                cwd,
2883                format!(
2884                    "could not verify untracked files in {} after a read-only call: {}. The \
2885                     worktree was kept for recovery.",
2886                    cwd.display(),
2887                    e.last_line()
2888                ),
2889            )
2890        })?;
2891        let leavings = baseline.ignored_untracked.read_only_leavings(&after);
2892        if leavings.condemning.is_empty() {
2893            return Ok(());
2894        }
2895        Err(uncertain_worktree_change(
2896            cwd,
2897            format!(
2898                "the read-only call left untracked file(s) in {} that the project does not \
2899                 ignore: {}. The worktree was kept for recovery.",
2900                cwd.display(),
2901                list_paths(&leavings.condemning)
2902            ),
2903        ))
2904    }
2905
2906    /// The ignored paths the check let through, for one report per attempt
2907    /// rather than one per check.
2908    fn allow_ignored_files(
2909        &self,
2910        cwd: &Path,
2911        baseline: &WorktreeBaseline,
2912    ) -> Result<AllowedArtifacts> {
2913        self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
2914    }
2915
2916    fn check_new_ignored_files(
2917        &self,
2918        cwd: &Path,
2919        baseline: &WorktreeBaseline,
2920        allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
2921    ) -> Result<AllowedArtifacts> {
2922        self.refuse_changed_attributes(cwd, baseline)?;
2923        let after = ignored_untracked_state(cwd).map_err(|e| {
2924            uncertain_worktree_change(
2925                cwd,
2926                format!(
2927                    "could not verify untracked files in {} after editing: {}. The worktree was \
2928                     kept for recovery.",
2929                    cwd.display(),
2930                    e.last_line()
2931                ),
2932            )
2933        })?;
2934        let changed = baseline.ignored_untracked.changed_paths(&after);
2935        if changed.is_empty() {
2936            return Ok(AllowedArtifacts::default());
2937        }
2938        let (ignored, changed): (Vec<_>, Vec<_>) = changed
2939            .into_iter()
2940            .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
2941        if changed.is_empty() {
2942            return Ok(AllowedArtifacts::split(ignored, &after));
2943        }
2944        let listed = list_paths(&changed);
2945        Err(uncertain_worktree_change(
2946            cwd,
2947            format!(
2948                "the editing call created or changed untracked or ignored file(s) in {} that \
2949                 cannot be represented by a managed commit: {listed}. The worktree was kept for \
2950                 recovery.",
2951                cwd.display()
2952            ),
2953        ))
2954    }
2955
2956    /// Existing untracked files belong to the checkout owner, even when a call
2957    /// also produces a valid tracked change. Refuse their modification or
2958    /// deletion before accepting the tracked result. Project-ignored files
2959    /// stay outside the commit, wherever the project puts them.
2960    pub(crate) fn refuse_changed_existing_untracked(
2961        &self,
2962        cwd: &Path,
2963        baseline: &WorktreeBaseline,
2964    ) -> Result<()> {
2965        self.check_changed_existing_untracked(cwd, baseline)
2966            .map(drop)
2967    }
2968
2969    fn allow_changed_ignored_artifacts(
2970        &self,
2971        cwd: &Path,
2972        baseline: &WorktreeBaseline,
2973    ) -> Result<AllowedArtifacts> {
2974        self.check_changed_existing_untracked(cwd, baseline)
2975    }
2976
2977    fn check_changed_existing_untracked(
2978        &self,
2979        cwd: &Path,
2980        baseline: &WorktreeBaseline,
2981    ) -> Result<AllowedArtifacts> {
2982        self.refuse_changed_attributes(cwd, baseline)?;
2983        let after = ignored_untracked_state(cwd).map_err(|e| {
2984            uncertain_worktree_change(
2985                cwd,
2986                format!(
2987                    "could not verify existing untracked files in {} after editing: {}. The \
2988                     worktree was kept for recovery.",
2989                    cwd.display(),
2990                    e.last_line()
2991                ),
2992            )
2993        })?;
2994        let changed = baseline.ignored_untracked.changed_existing_paths(&after);
2995        if changed.is_empty() {
2996            return Ok(AllowedArtifacts::default());
2997        }
2998        let (ignored, changed): (Vec<_>, Vec<_>) = changed
2999            .into_iter()
3000            .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3001        if changed.is_empty() {
3002            return Ok(AllowedArtifacts::split(ignored, &after));
3003        }
3004        let listed = list_paths(&changed);
3005        Err(uncertain_worktree_change(
3006            cwd,
3007            format!(
3008                "the editing call changed or deleted existing untracked file(s) in {}: \
3009                 {listed}. The worktree was kept for recovery.",
3010                cwd.display()
3011            ),
3012        ))
3013    }
3014
3015    /// Refuse a byte or mode change that the index did not represent.
3016    ///
3017    /// Clean filters can normalize a working file back to its existing blob,
3018    /// and index flags can hide a change from porcelain status. Comparing the
3019    /// actual tracked files on both sides keeps those bytes from being treated
3020    /// as disposable just because Git has no diff for them.
3021    pub(crate) fn refuse_unrepresented_tracked_changes(
3022        &self,
3023        cwd: &Path,
3024        baseline: &WorktreeBaseline,
3025    ) -> Result<()> {
3026        self.refuse_changed_attributes(cwd, baseline)?;
3027        let after = safe_git_state(cwd).map_err(|e| {
3028            uncertain_worktree_change(
3029                cwd,
3030                format!(
3031                    "could not verify tracked files in {} after editing: {}. The worktree was \
3032                     kept for recovery.",
3033                    cwd.display(),
3034                    e.last_line()
3035                ),
3036            )
3037        })?;
3038        let mut changed = Vec::new();
3039        let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3040            uncertain_worktree_change(
3041                cwd,
3042                format!(
3043                    "could not record untracked files before verifying transformed content in {}: \
3044                     {}. The worktree was kept for recovery.",
3045                    cwd.display(),
3046                    e.last_line()
3047                ),
3048            )
3049        })?;
3050        let mut filter_was_run = false;
3051        let mut filter_problem = None;
3052        let mut repositories: BTreeSet<PathBuf> =
3053            baseline.git_state.repositories.keys().cloned().collect();
3054        repositories.extend(after.repositories.keys().cloned());
3055        'repositories: for repository_path in repositories {
3056            let before_repository = baseline.git_state.repositories.get(&repository_path);
3057            let after_repository = after.repositories.get(&repository_path);
3058            if before_repository.is_none() || after_repository.is_none() {
3059                changed.push(repository_path.clone());
3060                continue;
3061            }
3062            if before_repository.map(|repository| &repository.gitlinks)
3063                != after_repository.map(|repository| &repository.gitlinks)
3064            {
3065                changed.push(repository_path.join("<gitlinks>"));
3066            }
3067            let mut paths = BTreeSet::new();
3068            if let Some(repository) = before_repository {
3069                paths.extend(repository.tracked.keys().cloned());
3070            }
3071            if let Some(repository) = after_repository {
3072                paths.extend(repository.tracked.keys().cloned());
3073            }
3074            for path in paths {
3075                let before = before_repository.and_then(|repository| repository.tracked.get(&path));
3076                let current = after_repository.and_then(|repository| repository.tracked.get(&path));
3077                let worktree_changed =
3078                    before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
3079                let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
3080                    != current.map(|entry| (&entry.index_mode, &entry.index_oid));
3081                if !worktree_changed {
3082                    continue;
3083                }
3084                if !index_changed {
3085                    changed.push(repository_path.join(&path));
3086                    continue;
3087                }
3088                let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
3089                let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
3090                let Some(current_entry) = current else {
3091                    continue;
3092                };
3093                let Some(current_worktree) = current_worktree else {
3094                    continue;
3095                };
3096                let content_changed =
3097                    before_worktree.map(|file| file.content) != Some(current_worktree.content);
3098                let mode_changed = before_worktree.map(|file| file.mode.as_str())
3099                    != Some(current_worktree.mode.as_str());
3100                let repository = cwd.join(&repository_path);
3101                let represented_content = if content_changed {
3102                    filter_was_run = true;
3103                    let result =
3104                        filtered_index_content(&repository, &path, &current_entry.index_oid);
3105                    self.refuse_changed_attributes(cwd, baseline)?;
3106                    match result {
3107                        Ok(expected) => expected == current_worktree.content,
3108                        Err(error) => {
3109                            filter_problem = Some(format!(
3110                                "could not verify transformed content for {:?}: {}",
3111                                repository_path.join(&path),
3112                                error.last_line()
3113                            ));
3114                            false
3115                        }
3116                    }
3117                } else {
3118                    true
3119                };
3120                let represented_mode =
3121                    !mode_changed || current_worktree.mode == current_entry.index_mode;
3122                if !represented_content || !represented_mode {
3123                    changed.push(repository_path.join(&path));
3124                }
3125                if filter_problem.is_some() {
3126                    break 'repositories;
3127                }
3128            }
3129        }
3130        if filter_was_run {
3131            self.refuse_changed_attributes(cwd, baseline)?;
3132            let verified = safe_git_state(cwd).map_err(|e| {
3133                uncertain_worktree_change(
3134                    cwd,
3135                    format!(
3136                        "could not recheck tracked files after verifying transformed content in \
3137                         {}: {}. The worktree was kept for recovery.",
3138                        cwd.display(),
3139                        e.last_line()
3140                    ),
3141                )
3142            })?;
3143            let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3144                uncertain_worktree_change(
3145                    cwd,
3146                    format!(
3147                        "could not recheck untracked files after verifying transformed content \
3148                         in {}: {}. The worktree was kept for recovery.",
3149                        cwd.display(),
3150                        e.last_line()
3151                    ),
3152                )
3153            })?;
3154            if verified != after
3155                || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3156            {
3157                return Err(uncertain_worktree_change(
3158                    cwd,
3159                    "a content filter changed the worktree while SPAR verified the managed \
3160                     commit. The worktree was kept for recovery.",
3161                ));
3162            }
3163            self.refuse_changed_existing_untracked(cwd, baseline)?;
3164        }
3165        if let Some(problem) = filter_problem {
3166            return Err(uncertain_worktree_change(
3167                cwd,
3168                format!("{problem}. The worktree was kept for recovery."),
3169            ));
3170        }
3171        if changed.is_empty() {
3172            return Ok(());
3173        }
3174        let listed = list_paths(&changed);
3175        Err(uncertain_worktree_change(
3176            cwd,
3177            format!(
3178                "the editing call changed tracked working-file bytes, modes, repositories, or \
3179                 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3180                 recovery."
3181            ),
3182        ))
3183    }
3184
3185    pub(crate) fn refuse_changed_attributes(
3186        &self,
3187        cwd: &Path,
3188        baseline: &WorktreeBaseline,
3189    ) -> Result<()> {
3190        let after = attribute_state(cwd).map_err(|e| {
3191            uncertain_worktree_change(
3192                cwd,
3193                format!(
3194                    "could not verify attribute files in {} after editing: {}. The worktree was \
3195                     kept for recovery.",
3196                    cwd.display(),
3197                    e.last_line()
3198                ),
3199            )
3200        })?;
3201        if after == baseline.attributes {
3202            return Ok(());
3203        }
3204        Err(uncertain_worktree_change(
3205            cwd,
3206            format!(
3207                "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
3208                 refused to run a Git operation that could select a new external filter.",
3209                cwd.display()
3210            ),
3211        ))
3212    }
3213
3214    /// Commit a successful editing call from the trusted harness process.
3215    ///
3216    /// Editing sandboxes only need the working tree. They never need writable
3217    /// access to the repository's object database, refs, config, or hooks.
3218    pub(crate) fn commit_pending_changes(
3219        &self,
3220        cwd: &Path,
3221        baseline: &WorktreeBaseline,
3222        preferred_subject: &str,
3223        fallback_subject: &str,
3224    ) -> Result<bool> {
3225        let mut artifacts = IgnoredArtifacts::default();
3226        self.refuse_changed_attributes(cwd, baseline)?;
3227        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3228        refuse_unsafe_index_flags(cwd)?;
3229        if !self.has_uncommitted_changes(cwd)? {
3230            artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3231            artifacts.report(cwd);
3232            return Ok(false);
3233        }
3234        self.stage_managed_changes(cwd, baseline).map_err(|e| {
3235            e.with_message(format!(
3236                "could not stage changes in {}: {}",
3237                cwd.display(),
3238                e.last_line()
3239            ))
3240        })?;
3241        // Git ignore rules decide which files stay outside the commit.
3242        // Whether cleanup can delete them is a separate decision.
3243        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3244        let changed_gitlinks = changed_staged_gitlinks(cwd)?;
3245        if !changed_gitlinks.is_empty() {
3246            let listed = list_paths(&changed_gitlinks);
3247            bail!(
3248                "the editing call added or changed a gitlink at {listed}. It was staged but not \
3249                 committed because the referenced repository objects might exist only inside \
3250                 this worktree. The worktree was kept for recovery."
3251            );
3252        }
3253        let mut subject = self.clean_title(preferred_subject)?;
3254        if subject.trim().is_empty() {
3255            subject = self.clean_title(fallback_subject)?;
3256        }
3257        self.commit_staged_changes(cwd, &subject).map_err(|e| {
3258            e.with_message(format!(
3259                "could not commit changes in {}: {}. The staged files were kept.",
3260                cwd.display(),
3261                e.last_line()
3262            ))
3263        })?;
3264        if has_tracked_or_staged_work(cwd)? {
3265            bail!(
3266                "the commit in {} left additional uncommitted files. They were kept for \
3267                 recovery.",
3268                cwd.display()
3269            );
3270        }
3271        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3272        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3273        artifacts.report(cwd);
3274        Ok(true)
3275    }
3276
3277    fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
3278        let after = ignored_untracked_state(cwd)?;
3279        self.git_at_without_automation(cwd, &["add", "-u"])?;
3280        let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
3281        if paths.is_empty() {
3282            return Ok(());
3283        }
3284        let mut input = Vec::new();
3285        for path in paths {
3286            input.extend(os_str_bytes(path.as_os_str())?);
3287            input.push(0);
3288        }
3289        let argv = git_without_automation_argv(&[
3290            "--literal-pathspecs",
3291            "add",
3292            "--pathspec-from-file=-",
3293            "--pathspec-file-nul",
3294        ]);
3295        proc::run_with_input_bytes(
3296            &argv,
3297            &self.git_opts(Some(cwd), true).stop_descendants(true),
3298            &input,
3299        )?;
3300        Ok(())
3301    }
3302
3303    /// Commit an index prepared by the parent without signing, hooks, or
3304    /// inherited repository automation.
3305    pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
3306        self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
3307            .map(|_| ())
3308    }
3309
3310    /// How many commits `refname` carries that the base does not.
3311    ///
3312    /// Counted from the commits themselves rather than from `commit_subjects`,
3313    /// which drops a commit whose message is empty. The guards in
3314    /// `worktree_add` decide whether to delete a branch on this number, and an
3315    /// empty message must not read as an empty branch.
3316    pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
3317        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3318        self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
3319            .trim()
3320            .parse()
3321            .unwrap_or(0)
3322    }
3323
3324    /// One `hash subject` line per commit `refname` carries that the base does
3325    /// not, oldest first. For showing a person what is on a branch, so the
3326    /// hash keeps a commit with no message from listing as nothing.
3327    pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3328        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3329        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3330            .lines()
3331            .map(str::to_string)
3332            .collect()
3333    }
3334
3335    /// The commits `later` carries that `earlier` does not, oldest first, when
3336    /// `earlier` is genuinely behind it.
3337    ///
3338    /// `None` when it is not an ancestor, which is not the same as nothing
3339    /// having landed. `rewrite_commits_if_needed` rewrites hashes from the first
3340    /// offending commit spar made onward, so a head recorded part way through a
3341    /// round can still be a readable object and no longer be on the branch. `git log` answers that
3342    /// with every commit on the branch, so without the check the one caller
3343    /// would report the whole branch as unread, which is the widest possible
3344    /// wrong answer.
3345    ///
3346    /// No `base_ref` resolution, unlike its neighbours: these are commits rather
3347    /// than branch names, and putting a sha through it logs a fallback line
3348    /// every time.
3349    pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3350        let ancestor = self
3351            .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3352            .is_ok();
3353        if !ancestor {
3354            return None;
3355        }
3356        let range = format!("{earlier}..{later}");
3357        Some(
3358            self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3359                .lines()
3360                .map(str::to_string)
3361                .collect(),
3362        )
3363    }
3364
3365    /// The subjects of the commits `refname` carries that the base does not,
3366    /// oldest first.
3367    pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3368        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3369        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3370            .lines()
3371            .map(str::trim)
3372            .filter(|line| !line.is_empty())
3373            .map(str::to_string)
3374            .collect()
3375    }
3376
3377    /// The paths this checkout changes relative to the base, sorted.
3378    ///
3379    /// A three dot range, matching `diff_stat`: what the branch did, not what
3380    /// the base has done since.
3381    ///
3382    /// `--no-renames` because a rename reported as its destination alone leaves
3383    /// the source out of the list, and a part carrying only the destination
3384    /// would be a copy. As a deletion and an addition it is two paths, which a
3385    /// part can carry together or leave to the leftover report.
3386    ///
3387    /// `-z` because without it git writes paths for display: anything
3388    /// non-ASCII comes back escaped and wrapped in quotes, and that string is
3389    /// not a path. A part built from one carries a pathspec matching no file,
3390    /// so the file never reaches the slice while every list still says the part
3391    /// took it. It also keeps a path with a space at either end intact.
3392    pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
3393        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3394        self.git_try_at(
3395            Some(cwd),
3396            &["diff", "--name-only", "--no-renames", "-z", &range],
3397        )
3398        .split('\0')
3399        .filter(|path| !path.is_empty())
3400        .map(str::to_string)
3401        .collect()
3402    }
3403
3404    /// The files one commit range touched, for checking a claim against the
3405    /// diff rather than against the report that made it.
3406    pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
3407        self.git_try_at(
3408            Some(cwd),
3409            &[
3410                "diff",
3411                "--name-only",
3412                "--no-renames",
3413                "-z",
3414                &format!("{from}..{to}"),
3415            ],
3416        )
3417        .split('\0')
3418        .filter(|path| !path.is_empty())
3419        .map(str::to_string)
3420        .collect()
3421    }
3422
3423    /// Where `refname` left the base: the commit its own change is measured
3424    /// from, and the one a slice of that change has to be taken against.
3425    pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
3426        let base_ref = self.base_ref(cwd, base);
3427        let out = self
3428            .git_at(Some(cwd), &["merge-base", &base_ref, refname])
3429            .map_err(|e| {
3430                spar_err!(
3431                    "could not find where {refname} and {base_ref} diverged. {}",
3432                    e.last_line()
3433                )
3434            })?;
3435        let sha = out.trim().to_string();
3436        if sha.is_empty() {
3437            bail!("{refname} and {base_ref} share no history");
3438        }
3439        Ok(sha)
3440    }
3441
3442    pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
3443        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3444        let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
3445        full.trim().to_string()
3446    }
3447
3448    /// Scrub commit messages that slipped past the prompt.
3449    ///
3450    /// `git filter-branch` calls back into this same binary, so there is no
3451    /// interpreter to find and no second copy of the rules to drift.
3452    ///
3453    /// `floor` is the head spar found when it started, and nothing at or below
3454    /// it is rewritten. The style gate is for what spar writes, and a person's
3455    /// commit message is not that: rewriting one changes a published SHA, which
3456    /// diverges the branch under somebody's own checkout and is exactly the
3457    /// history rewrite this design promises never to do. A `None` floor means
3458    /// every commit above the base is spar's own, which is true of a branch
3459    /// spar created in this invocation and of nothing else.
3460    pub fn rewrite_commits_if_needed(
3461        &self,
3462        cwd: &Path,
3463        base: &str,
3464        floor: Option<&str>,
3465    ) -> Result<()> {
3466        let base_ref = self.base_ref(cwd, base);
3467        let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
3468        self.report_commits_left_alone(cwd, &base_ref, floor);
3469
3470        let range = format!("{floor}..HEAD");
3471        let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
3472
3473        let offenders = raw
3474            .split('\x1e')
3475            .filter_map(|entry| entry.split_once('\0'))
3476            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3477            .count();
3478        if offenders == 0 {
3479            return Ok(());
3480        }
3481        logdim!("{offenders} commit message(s) violated style rules, rewriting");
3482
3483        let exe = self_binary()?;
3484        let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
3485
3486        let argv: Vec<String> = [
3487            "git",
3488            "filter-branch",
3489            "-f",
3490            "--msg-filter",
3491            &filter,
3492            &range,
3493        ]
3494        .iter()
3495        .map(|s| s.to_string())
3496        .collect();
3497        let opts = ExecOpts::new()
3498            .cwd(cwd)
3499            .check(false)
3500            .timeout_secs(600)
3501            .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
3502            .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
3503            .env(
3504                "SPAR_BAN_AI_ATTRIBUTION",
3505                bool_env(self.style.ban_ai_attribution),
3506            );
3507        let _ = proc::run(&argv, &opts);
3508
3509        let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
3510        if !style::violations(&after, &self.style).is_empty() {
3511            bail!(
3512                "commit messages still violate style rules after a rewrite in {}.",
3513                cwd.display()
3514            );
3515        }
3516        Ok(())
3517    }
3518
3519    /// Say out loud when a message spar will not touch breaks the style rules.
3520    ///
3521    /// Silence here would read as "there was nothing to scrub", and the point
3522    /// of the floor is that there was something and spar chose to leave it.
3523    fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
3524        if floor == base_ref {
3525            return;
3526        }
3527        let raw = self.git_try_at(
3528            Some(cwd),
3529            &[
3530                "log",
3531                &format!("{base_ref}..{floor}"),
3532                "--format=%H%x00%B%x1e",
3533            ],
3534        );
3535        let left: Vec<&str> = raw
3536            .split('\x1e')
3537            .filter_map(|entry| entry.split_once('\0'))
3538            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3539            .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
3540            .collect();
3541        if left.is_empty() {
3542            return;
3543        }
3544        logdim!(
3545            "{} commit message(s) already on the branch break the style rules and were left \
3546             alone: {}. spar does not rewrite commits it did not write.",
3547            left.len(),
3548            left.join(", ")
3549        );
3550    }
3551
3552    /// Push by explicit refspec from HEAD, leasing the head spar reviewed.
3553    ///
3554    /// A resumed PR is checked out under a local name (`pr-N`) that does not
3555    /// match its remote branch, so pushing by branch name would resolve the
3556    /// wrong local ref or fail outright.
3557    ///
3558    /// `expected` is the remote head spar last read. A bare `--force-with-lease`
3559    /// leases the implicit tracking ref instead, which is shared across every
3560    /// worktree and is updated by any `git fetch`. The editing agent is a real
3561    /// CLI with shell access, and fetching is a normal thing for it to do: one
3562    /// `git fetch` mid round moves the tracking ref to whatever the remote holds
3563    /// and the lease then passes against a head spar never reviewed, which
3564    /// silently drops somebody's commit. Naming the head makes the push refuse
3565    /// exactly that case.
3566    pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
3567        let expected = expected.map(str::trim).filter(|head| !head.is_empty());
3568        if let Some(head) = expected {
3569            self.refuse_push_that_drops(cwd, head)?;
3570        }
3571        let refspec = format!("HEAD:{branch}");
3572        let lease = match expected {
3573            Some(head) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
3574            None => "--force-with-lease".to_string(),
3575        };
3576        let pushed = self
3577            .git_at(Some(cwd), &["push", &lease, "origin", &refspec])
3578            .map(|_| ())
3579            .map_err(|e| {
3580                spar_err!(
3581                    "could not push to origin/{branch}. {}\nCheck push access and whether the \
3582                     branch moved under you.",
3583                    e.last_line()
3584                )
3585            });
3586        self.record_write(pushed)
3587    }
3588
3589    /// Refuse a push whose HEAD does not contain the head spar reviewed.
3590    ///
3591    /// The lease answers the remote moving. This answers the local side moving:
3592    /// an agent that reset or rebased the worktree leaves a HEAD that would
3593    /// publish over the reviewed head rather than on top of it, and the lease
3594    /// cannot tell the difference because the remote is exactly where spar left
3595    /// it.
3596    fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
3597        let head = self.head_oid_checked(cwd)?;
3598        if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
3599            return Ok(());
3600        }
3601        bail!(
3602            "refusing to push {head} because it does not contain the reviewed head {expected}. \
3603             The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
3604             the intended commits on top of it before resuming.",
3605            cwd.display()
3606        )
3607    }
3608
3609    /// Create one remote branch for a split without ever moving an existing ref.
3610    ///
3611    /// `worktree_for_split` chooses a name that is free locally and on origin,
3612    /// but another writer can still take it before the push. An empty expected
3613    /// value in the lease makes this an atomic create: it creates an absent ref,
3614    /// accepts an identical ref as a no-op, and never moves an existing ref.
3615    /// The shared `push` method cannot be used because its lease permits
3616    /// updating a ref fetched earlier.
3617    pub fn push_split_branch(
3618        &self,
3619        cwd: &Path,
3620        branch: &str,
3621    ) -> std::result::Result<(), SplitPushError> {
3622        let remote_ref = format!("refs/heads/{branch}");
3623        let lease = format!("--force-with-lease={remote_ref}:");
3624        let refspec = format!("HEAD:{remote_ref}");
3625        let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
3626            Ok(_) => Ok(()),
3627            Err(push_error) => {
3628                let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
3629                let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
3630                reconcile_failed_split_push(branch, push_error, local, remote)
3631            }
3632        };
3633        self.record_write(result)
3634    }
3635
3636    // -- gh ---------------------------------------------------------------
3637
3638    pub fn gh(&self, args: &[&str]) -> Result<String> {
3639        self.gh_at(None, args)
3640    }
3641
3642    pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
3643        let mut argv = vec!["gh".to_string()];
3644        argv.extend(args.iter().map(|s| s.to_string()));
3645        proc::run(
3646            &argv,
3647            &ExecOpts::new()
3648                .cwd(cwd.unwrap_or(&self.root))
3649                .timeout_secs(300),
3650        )
3651    }
3652
3653    /// Run gh with something on its stdin.
3654    ///
3655    /// A tracker body is far too long to pass on argv, and `--body-file -` is
3656    /// how gh takes one. `proc::exec` already wires the pipe, so this is a
3657    /// sibling of `gh_at` rather than anything new.
3658    pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
3659        let mut argv = vec!["gh".to_string()];
3660        argv.extend(args.iter().map(|s| s.to_string()));
3661        proc::run(
3662            &argv,
3663            &ExecOpts::new()
3664                .cwd(&self.root)
3665                .timeout_secs(300)
3666                .stdin(stdin),
3667        )
3668    }
3669
3670    pub fn gh_try(&self, args: &[&str]) -> String {
3671        let mut argv = vec!["gh".to_string()];
3672        argv.extend(args.iter().map(|s| s.to_string()));
3673        proc::run(
3674            &argv,
3675            &ExecOpts::new()
3676                .cwd(&self.root)
3677                .check(false)
3678                .timeout_secs(300),
3679        )
3680        .unwrap_or_default()
3681    }
3682
3683    /// `gh api -i`: the status line and headers with the body, and the exit
3684    /// code ignored on purpose. A `304 Not Modified`, which is the answer a
3685    /// conditional poll wants, makes gh exit 1 while printing exactly what is
3686    /// needed. Only a spawn failure or a timeout is an error here.
3687    pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
3688        let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
3689        for (name, value) in headers {
3690            argv.push("-H".to_string());
3691            argv.push(format!("{name}: {value}"));
3692        }
3693        argv.push(endpoint.to_string());
3694        let out = proc::exec(
3695            &argv,
3696            &ExecOpts::new()
3697                .cwd(&self.root)
3698                .check(false)
3699                .timeout_secs(300),
3700        )?;
3701        parse_api_response(&out.stdout)
3702            .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
3703    }
3704
3705    /// Every open pull request, with what a sweep needs to know about each.
3706    pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
3707        let text = self.gh(&[
3708            "pr",
3709            "list",
3710            "--state",
3711            "open",
3712            "--limit",
3713            "500",
3714            "--json",
3715            PR_SWEEP_FIELDS,
3716        ])?;
3717        serde_json::from_str(&text)
3718            .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
3719    }
3720
3721    /// One pull request, open or not, with what a sweep needs to know.
3722    pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
3723        let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
3724        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
3725    }
3726
3727    /// How many commits lie between two SHAs, when GitHub will say.
3728    pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
3729        if from.is_empty() || to.is_empty() {
3730            return None;
3731        }
3732        let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
3733        self.gh_try(&["api", &path, "--jq", ".total_commits"])
3734            .trim()
3735            .parse()
3736            .ok()
3737    }
3738
3739    /// `owner/name`, for the line that says what is being watched.
3740    pub fn repo_name(&self) -> Option<String> {
3741        let name = self.gh_try(&[
3742            "repo",
3743            "view",
3744            "--json",
3745            "nameWithOwner",
3746            "--jq",
3747            ".nameWithOwner",
3748        ]);
3749        let name = name.trim();
3750        (!name.is_empty()).then(|| name.to_string())
3751    }
3752
3753    pub fn listen_state_path(&self) -> PathBuf {
3754        self.root.join(STATE_DIR).join("state").join("listen.json")
3755    }
3756
3757    pub fn listen_lock_path(&self) -> PathBuf {
3758        self.root.join(STATE_DIR).join("state").join("listen.lock")
3759    }
3760
3761    /// The login `gh` is authenticated as.
3762    ///
3763    /// A hard error, never a degradation. Everything spar wrote has to be
3764    /// excluded from what it answers, and custody cannot be read from git
3765    /// authorship, so this is the only thing that tells spar's own comments
3766    /// from somebody else's. Without it the failure is not "answers a bit too
3767    /// much", it is a thread where spar answers itself until somebody notices.
3768    ///
3769    /// Not cached on disk: `gh auth switch` between runs would make a stored
3770    /// answer wrong in exactly the way that produces that thread.
3771    pub fn viewer_login(&self) -> Result<&str> {
3772        if let Some(login) = self.viewer.get() {
3773            return Ok(login);
3774        }
3775        let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
3776        let login = if !rest.trim().is_empty() {
3777            rest.trim().to_string()
3778        } else {
3779            // A token that cannot read /user can still answer for itself in
3780            // GraphQL, which is the case on some Enterprise installs.
3781            self.gh(&[
3782                "api",
3783                "graphql",
3784                "-f",
3785                "query={ viewer { login } }",
3786                "--jq",
3787                ".data.viewer.login",
3788            ])
3789            .map_err(|e| {
3790                spar_err!(
3791                    "could not find out who `gh` is authenticated as, so spar cannot tell its \
3792                     own comments from anybody else's. {}\nRun `gh auth status`.",
3793                    e.last_line()
3794                )
3795            })?
3796            .trim()
3797            .to_string()
3798        };
3799        if login.is_empty() {
3800            bail!("`gh` reported an empty login. Run `gh auth status`.");
3801        }
3802        Ok(self.viewer.get_or_init(|| login))
3803    }
3804
3805    /// One issue as it stands, open or closed.
3806    ///
3807    /// `fetch_issues` reads a queue to work: it drops a closed issue and fails
3808    /// when nothing survives. Both are wrong for reading one issue back, where
3809    /// closed is an answer and the empty case cannot arise.
3810    pub fn read_issue(&self, number: i64) -> Result<Issue> {
3811        let text = self
3812            .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
3813            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3814        serde_json::from_str(&text)
3815            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
3816    }
3817
3818    pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
3819        let mut issues = Vec::new();
3820        for number in numbers {
3821            let text = self
3822                .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
3823                .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3824            let issue: Issue = serde_json::from_str(&text)
3825                .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3826            if issue.is_closed() {
3827                crate::log!("issue #{number} is closed, skipping");
3828                continue;
3829            }
3830            issues.push(issue);
3831        }
3832        if issues.is_empty() {
3833            bail!("no open issues to work on");
3834        }
3835        Ok(issues)
3836    }
3837
3838    /// Open items, lowest numbered first, from `min_number` upward.
3839    ///
3840    /// The floor exists because a long lived repository accumulates a tail of
3841    /// old issues nobody is going to get to, and taking the lowest numbered
3842    /// open items means walking straight into them.
3843    fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3844        #[derive(Deserialize)]
3845        struct Row {
3846            number: i64,
3847        }
3848        let text = self.gh(&[
3849            kind,
3850            "list",
3851            "--state",
3852            "open",
3853            "--limit",
3854            &FETCH_CEILING.to_string(),
3855            "--json",
3856            "number",
3857        ])?;
3858        let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
3859        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
3860        numbers.sort_unstable();
3861
3862        let noun = if kind == "issue" { "issues" } else { "PRs" };
3863        let found = numbers.len();
3864        if min_number > 0 {
3865            numbers.retain(|n| *n >= min_number);
3866            let skipped = found - numbers.len();
3867            if skipped > 0 {
3868                crate::log!("{skipped} open {noun} below #{min_number} skipped");
3869            }
3870        }
3871        if found >= FETCH_CEILING {
3872            crate::log!(
3873                "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
3874                 considered."
3875            );
3876        }
3877        if numbers.len() > limit {
3878            crate::log!(
3879                "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
3880                 explicitly.",
3881                numbers.len()
3882            );
3883            numbers.truncate(limit);
3884        }
3885        Ok(numbers)
3886    }
3887
3888    /// Open issues, lowest numbered first. `gh issue list` excludes PRs.
3889    pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3890        self.open_numbers("issue", limit, min_number)
3891    }
3892
3893    pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3894        self.open_numbers("pr", limit, min_number)
3895    }
3896
3897    pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
3898        self.branch_prs(branch, "open").into_iter().next()
3899    }
3900
3901    /// The open pull request for a branch, preserving a failed lookup as an
3902    /// error when the caller is deciding whether a write already landed.
3903    pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
3904        let text = self.gh(&[
3905            "pr",
3906            "list",
3907            "--head",
3908            branch,
3909            "--base",
3910            base,
3911            "--state",
3912            "open",
3913            "--json",
3914            "number,url,title,baseRefName",
3915        ])?;
3916        pr_for_base(&text, branch, base)
3917    }
3918
3919    /// Every pull request opened from this branch, merged and closed ones
3920    /// included, because a commit is preserved by whichever one carries it and
3921    /// that is rarely the newest.
3922    fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
3923        self.branch_prs(branch, "all")
3924    }
3925
3926    fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
3927        let text = self.gh_try(&[
3928            "pr",
3929            "list",
3930            "--head",
3931            branch,
3932            "--state",
3933            state,
3934            "--json",
3935            "number,url,title",
3936        ]);
3937        serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
3938    }
3939
3940    /// Whether a number names an issue or a pull request.
3941    ///
3942    /// `gh issue view` happily returns a pull request when handed its number,
3943    /// so it cannot be used to tell them apart. The issues API carries both and
3944    /// marks a pull request with a `pull_request` key, which is definitive.
3945    pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
3946        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
3947        let text = self
3948            .gh(&[
3949                "api",
3950                &path,
3951                "--jq",
3952                "if .pull_request then \"pr\" else \"issue\" end",
3953            ])
3954            .map_err(|e| {
3955                spar_err!(
3956                    "no issue or pull request #{number} in this repository. {}",
3957                    e.last_line()
3958                )
3959            })?;
3960        match text.trim() {
3961            "pr" => Ok(ItemKind::Pr),
3962            "issue" => Ok(ItemKind::Issue),
3963            other => Err(spar_err!(
3964                "could not tell whether #{number} is an issue or a pull request (got {other:?})"
3965            )),
3966        }
3967    }
3968
3969    /// An open pull request that would close this issue, whoever opened it.
3970    ///
3971    /// spar's own branch naming is checked first because it is exact and cheap.
3972    /// Falling back to GitHub's own issue linkage is what lets spar pick up a
3973    /// pull request a person started on a branch named anything at all.
3974    ///
3975    /// An error rather than `None` when the question could not be asked. The
3976    /// two answers are not interchangeable: "no pull request exists" is the one
3977    /// that makes spar implement over the top of somebody's work and open a
3978    /// second pull request for the same issue.
3979    pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
3980        let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
3981        if let Some(pr) = mine {
3982            return Ok(Some(pr));
3983        }
3984        let listed = match self.gh(&[
3985            "pr",
3986            "list",
3987            "--state",
3988            "open",
3989            "--limit",
3990            &FETCH_CEILING.to_string(),
3991            "--json",
3992            "number,url,title,baseRefName,closingIssuesReferences",
3993        ]) {
3994            Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
3995                spar_err!("could not read the pull request list while looking for #{issue}")
3996            })?,
3997            Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
3998        };
3999        if listed.len() >= FETCH_CEILING {
4000            logwarn!(
4001                "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a                  pull request past it cannot be seen from here."
4002            );
4003        }
4004        let default = self.default_branch(base);
4005        if !base.eq_ignore_ascii_case(&default) {
4006            // GitHub populates closingIssuesReferences only for pull requests
4007            // targeting the default branch, so on any other base the linkage is
4008            // silence rather than an answer.
4009            logwarn!(
4010                "#{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."
4011            );
4012        }
4013        Ok(pick_linked_pr(&listed, issue))
4014    }
4015
4016    /// spar's own branch, with a failed lookup kept as a failure.
4017    fn pr_for_branch_failing_closed(
4018        &self,
4019        branch: &str,
4020        base: &str,
4021        issue: i64,
4022    ) -> Result<Option<PrRef>> {
4023        match self.try_pr_for_branch(branch, base) {
4024            Ok(found) => Ok(found),
4025            Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
4026        }
4027    }
4028
4029    /// What to do when GitHub would not say whether a pull request exists.
4030    ///
4031    /// An error, because "no pull request exists" is the answer that makes spar
4032    /// implement from scratch and open a second pull request over somebody's
4033    /// work. The one exception is a repository gh cannot reach at all: there is
4034    /// no pull request to collide with when there is no GitHub repository in
4035    /// play, and every later write would fail anyway. That case is a warning and
4036    /// a `None`, so a run against a plain git remote behaves as it always did.
4037    fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
4038        if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
4039            return Err(spar_err!(
4040                "could not check whether a pull request already exists for #{issue}: {}. \
4041                 Refusing to implement over the top of work that may already be open.",
4042                e.last_line()
4043            ));
4044        }
4045        logwarn!(
4046            "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
4047             whether a pull request already exists for it.",
4048            e.last_line()
4049        );
4050        Ok(())
4051    }
4052
4053    pub fn pr_view(&self, number: i64) -> Result<PrView> {
4054        let text = self.gh(&[
4055            "pr",
4056            "view",
4057            &number.to_string(),
4058            "--json",
4059            "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
4060        ])?;
4061        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4062    }
4063
4064    fn try_pr_state(&self, number: i64) -> Result<String> {
4065        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
4066        serde_json::from_str::<Value>(text.trim())
4067            .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
4068            .get("state")
4069            .and_then(Value::as_str)
4070            .map(str::to_string)
4071            .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
4072    }
4073
4074    pub fn pr_state(&self, number: i64) -> String {
4075        self.try_pr_state(number).unwrap_or_default()
4076    }
4077
4078    /// The commit currently exposed as a pull request's head.
4079    pub fn pr_head_oid(&self, number: i64) -> Result<String> {
4080        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
4081        let oid = serde_json::from_str::<Value>(&text)
4082            .ok()
4083            .and_then(|value| {
4084                value
4085                    .get("headRefOid")
4086                    .and_then(Value::as_str)
4087                    .map(str::trim)
4088                    .filter(|oid| !oid.is_empty())
4089                    .map(str::to_string)
4090            })
4091            .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
4092        Ok(oid)
4093    }
4094
4095    pub fn create_pr(
4096        &self,
4097        cwd: &Path,
4098        branch: &str,
4099        base: &str,
4100        title: &str,
4101        body: &str,
4102    ) -> Result<PrRef> {
4103        let title = self.record_failed_write(self.clean_title(title))?;
4104        let body = self.record_failed_write(self.clean(body))?;
4105        let mut argv = vec![
4106            "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
4107        ];
4108        if self.drafts != Drafts::Never {
4109            argv.push("--draft");
4110        }
4111        let created = self.gh_at(Some(cwd), &argv);
4112        let found = self.try_pr_for_branch(branch, base);
4113        self.record_write(reconcile_pr_creation(branch, created, found))
4114    }
4115
4116    pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
4117        let body = signed(&self.record_failed_write(self.clean(body))?);
4118        let comments = self.record_failed_write(self.try_issue_comments(number))?;
4119        if has_exact_comment(&comments, &body) {
4120            return Ok(());
4121        }
4122        let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
4123        let result = match posted {
4124            Ok(_) => Ok(()),
4125            Err(post_error) => {
4126                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
4127            }
4128        };
4129        self.record_write(result)
4130    }
4131
4132    /// Whether the viewer wrote this pull request. GitHub refuses to let an
4133    /// author approve or request changes on their own, so a verdict on one can
4134    /// only ever be a comment.
4135    fn pr_is_own(&self, number: i64) -> Result<bool> {
4136        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
4137        let author = serde_json::from_str::<Value>(&text)
4138            .ok()
4139            .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
4140            .unwrap_or_default();
4141        let viewer = self.viewer_login()?.to_string();
4142        Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
4143    }
4144
4145    /// Submit a formal review, or a comment carrying the verdict's words when
4146    /// GitHub will not take the verdict: the viewer's own pull request, or no
4147    /// verdict at all.
4148    ///
4149    /// Mirrors `comment_pr` step for step: the style gate, the signature, a
4150    /// duplicate check against the viewer's own reviews, the write, and a read
4151    /// back on failure so an unverifiable write is reported as one.
4152    ///
4153    /// `commit` is the commit the reviewers read. GitHub attaches a review
4154    /// that names no commit to whatever the head is when it arrives, so a push
4155    /// made while a review ran would otherwise receive an approval nobody
4156    /// gave it. `gh pr review` cannot name one, hence the REST call.
4157    pub fn submit_review(
4158        &self,
4159        number: i64,
4160        verdict: Option<crate::model::ReviewVerdict>,
4161        body: &str,
4162        commit: &str,
4163    ) -> Result<()> {
4164        let own = match verdict {
4165            Some(_) => self.record_failed_write(self.pr_is_own(number))?,
4166            None => false,
4167        };
4168        let text = match (verdict, own) {
4169            (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
4170            _ => body.to_string(),
4171        };
4172        let body = signed(&self.record_failed_write(self.clean(&text))?);
4173        let viewer = self.record_failed_write(self.viewer_login())?.to_string();
4174        let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
4175        if has_viewer_review(&reviews, &viewer, &body, commit) {
4176            return Ok(());
4177        }
4178        let event = match (verdict, own) {
4179            (Some(v), false) => v.api_event(),
4180            _ => "COMMENT",
4181        };
4182        let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
4183        let posted = self.gh(&[
4184            "api",
4185            "-X",
4186            "POST",
4187            &path,
4188            "-f",
4189            &format!("commit_id={commit}"),
4190            "-f",
4191            &format!("event={event}"),
4192            "-f",
4193            &format!("body={body}"),
4194            "--silent",
4195        ]);
4196        let result = match posted {
4197            Ok(_) => Ok(()),
4198            Err(post_error) => reconcile_review_post(
4199                number,
4200                &body,
4201                &viewer,
4202                commit,
4203                post_error,
4204                self.try_pr_reviews(number),
4205            ),
4206        };
4207        self.record_write(result)
4208    }
4209
4210    pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
4211        let body = signed(&self.record_failed_write(self.clean(body))?);
4212        let comments = self.record_failed_write(self.try_issue_comments(number))?;
4213        if has_exact_comment(&comments, &body) {
4214            return Ok(());
4215        }
4216        let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
4217        let result = match posted {
4218            Ok(_) => Ok(()),
4219            Err(post_error) => {
4220                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
4221            }
4222        };
4223        self.record_write(result)
4224    }
4225
4226    /// Comment, then close as not planned.
4227    ///
4228    /// Only ever called when both agents independently declined the issue: one
4229    /// agent's opinion is not enough to close somebody's report.
4230    pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
4231        self.comment_issue(number, body)?;
4232        let n = number.to_string();
4233        let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
4234            Ok(_) => Ok(()),
4235            // Older gh builds do not take --reason.
4236            Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
4237                spar_err!(
4238                    "commented on #{number} but could not close it: {}",
4239                    e.last_line()
4240                )
4241            }),
4242        };
4243        self.record_write(closed)
4244    }
4245
4246    /// Replace an issue body, refusing unless it is still byte for byte what
4247    /// the caller read and validating only the fragment spar inserted.
4248    ///
4249    /// The only place spar rewrites text somebody else wrote, so the check is
4250    /// the whole point: an edit computed from a body that has since moved would
4251    /// silently delete whatever moved it. The caller decides whether another
4252    /// attempt is safe for its workflow.
4253    ///
4254    /// Deliberately not through `clean_issue_body`. The body is mostly a
4255    /// person's own prose, and the scrub would rewrite their punctuation while
4256    /// the length budget could truncate the end of a long report. `inserted` is
4257    /// the only text here spar is answerable for, so it still passes through the
4258    /// style gate. The full body travels over stdin because a tracker can be far
4259    /// too long for one argument.
4260    pub fn edit_issue_body(
4261        &self,
4262        number: i64,
4263        expected: &str,
4264        body: &str,
4265        inserted: &str,
4266    ) -> Result<()> {
4267        let cleaned = self.record_failed_write(self.clean(inserted))?;
4268        if cleaned.trim() != inserted.trim() {
4269            return self.record_failed_write(Err(spar_err!(
4270                "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
4271            )));
4272        }
4273        let current = self.record_failed_write(self.issue_body(number))?;
4274        if current != expected {
4275            return self.record_failed_write(Err(spar_err!(
4276                "the body of #{number} changed since it was read, so it was left alone rather \
4277                 than written over."
4278            )));
4279        }
4280        let edited = self.gh_stdin(
4281            &["issue", "edit", &number.to_string(), "--body-file", "-"],
4282            body,
4283        );
4284        let result = match edited {
4285            Ok(_) => Ok(()),
4286            Err(edit_error) => {
4287                reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
4288            }
4289        };
4290        self.record_write(result)
4291    }
4292
4293    /// One issue's body, exactly as GitHub holds it.
4294    pub fn issue_body(&self, number: i64) -> Result<String> {
4295        #[derive(Deserialize)]
4296        struct Row {
4297            #[serde(default)]
4298            body: Option<String>,
4299        }
4300        let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
4301        let row: Row = serde_json::from_str(text.trim())
4302            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4303        Ok(row.body.unwrap_or_default())
4304    }
4305
4306    /// Every open issue with its title and body, in one call.
4307    ///
4308    /// For a screen that has to say something about each of twenty items before
4309    /// anything expensive happens. One call rather than one per issue.
4310    pub fn open_issue_rows(&self) -> Vec<Issue> {
4311        let text = self.gh_try(&[
4312            "issue",
4313            "list",
4314            "--state",
4315            "open",
4316            "--limit",
4317            &FETCH_CEILING.to_string(),
4318            "--json",
4319            "number,title,body,labels,state,stateReason,url",
4320        ]);
4321        serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
4322    }
4323
4324    /// Every open pull request with its size, in one call.
4325    pub fn open_pr_rows(&self) -> Vec<PrRow> {
4326        let text = self.gh_try(&[
4327            "pr",
4328            "list",
4329            "--state",
4330            "open",
4331            "--limit",
4332            &FETCH_CEILING.to_string(),
4333            "--json",
4334            "number,title,changedFiles,additions,deletions",
4335        ]);
4336        serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
4337    }
4338
4339    pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
4340        self.create_issue_apart_from(title, body, None)
4341    }
4342
4343    pub fn create_issue_apart_from(
4344        &self,
4345        title: &str,
4346        body: &str,
4347        apart_from: Option<i64>,
4348    ) -> Result<String> {
4349        let title = self.record_failed_write(self.clean_title(title))?;
4350        let body = self.record_failed_write(self.clean_issue_body(body))?;
4351        let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
4352        let result = match created {
4353            Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
4354            created => {
4355                let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
4356                reconcile_issue_creation(&title, created, found)
4357            }
4358        };
4359        self.record_write(result)
4360    }
4361}
4362
4363/// An issue that already covers what spar was about to file.
4364#[derive(Debug, Clone)]
4365pub struct ExistingIssue {
4366    pub number: i64,
4367    pub url: String,
4368    pub title: String,
4369    pub body: String,
4370    pub open: bool,
4371}
4372
4373impl Repo {
4374    pub(crate) fn try_exact_issue_apart_from(
4375        &self,
4376        title: &str,
4377        body: &str,
4378        apart_from: Option<i64>,
4379    ) -> Result<Option<ExistingIssue>> {
4380        #[derive(Deserialize)]
4381        #[serde(rename_all = "camelCase")]
4382        struct Row {
4383            number: i64,
4384            #[serde(default)]
4385            title: String,
4386            #[serde(default)]
4387            url: String,
4388            #[serde(default)]
4389            body: Option<String>,
4390            #[serde(default)]
4391            state: String,
4392        }
4393
4394        let text = self.gh(&[
4395            "issue",
4396            "list",
4397            "--state",
4398            "all",
4399            "--limit",
4400            "100",
4401            "--json",
4402            "number,title,url,body,state,stateReason",
4403        ])?;
4404        let rows = serde_json::from_str::<Vec<Row>>(text.trim())
4405            .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
4406        Ok(rows
4407            .into_iter()
4408            .filter(|row| Some(row.number) != apart_from)
4409            .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
4410            .map(|row| ExistingIssue {
4411                number: row.number,
4412                url: row.url,
4413                title: row.title,
4414                body: row.body.unwrap_or_default(),
4415                open: row.state.eq_ignore_ascii_case("open"),
4416            }))
4417    }
4418
4419    /// An issue that already describes this defect, however it was worded.
4420    ///
4421    /// Exact title matching let duplicates through: two agents, or two runs a
4422    /// week apart, never word one defect identically. A real run filed two
4423    /// duplicates that way, and each had to be closed by hand afterwards.
4424    /// Titles alone are too thin to match on, so this compares titles and
4425    /// bodies together.
4426    pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
4427        self.find_similar_issue_apart_from(title, body, None)
4428    }
4429
4430    /// The same search, with one issue that cannot be its own duplicate.
4431    ///
4432    /// A tracker's body quotes every item in its checklist, so searching for an
4433    /// item's words matches the tracker before it matches anything else. That
4434    /// would link an item to the issue it is written in.
4435    /// The search a tracker runs, which wants closed issues too: a checklist
4436    /// item matching a closed issue is a done item that wants a link.
4437    pub fn find_similar_issue_apart_from(
4438        &self,
4439        title: &str,
4440        body: &str,
4441        apart_from: Option<i64>,
4442    ) -> Option<ExistingIssue> {
4443        self.try_similar_issue(title, body, apart_from, true)
4444            .ok()
4445            .flatten()
4446    }
4447
4448    /// The search a filing runs, over open issues only.
4449    pub fn try_find_similar_issue_apart_from(
4450        &self,
4451        title: &str,
4452        body: &str,
4453        apart_from: Option<i64>,
4454    ) -> Result<Option<ExistingIssue>> {
4455        self.try_similar_issue(title, body, apart_from, false)
4456    }
4457
4458    /// The same search, preserving lookup failure for a caller about to write.
4459    fn try_similar_issue(
4460        &self,
4461        title: &str,
4462        body: &str,
4463        apart_from: Option<i64>,
4464        include_closed: bool,
4465    ) -> Result<Option<ExistingIssue>> {
4466        #[derive(Deserialize)]
4467        #[serde(rename_all = "camelCase")]
4468        struct Row {
4469            number: i64,
4470            #[serde(default)]
4471            title: String,
4472            #[serde(default)]
4473            url: String,
4474            #[serde(default)]
4475            body: String,
4476            #[serde(default)]
4477            state: String,
4478        }
4479        if title.trim().is_empty() {
4480            return Ok(None);
4481        }
4482        // Search on the title's own words: GitHub's index is the cheap way to
4483        // narrow the field before comparing properly.
4484        let query: String = title
4485            .chars()
4486            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4487            .take(120)
4488            .collect();
4489        // Open issues only when something is about to be filed. A fuzzy match
4490        // against a closed one is the case worth keeping rather than dropping:
4491        // a defect that was fixed, closed, and has come back is exactly the
4492        // finding a maintainer wants, and "#N covers it and is closed" wrote it
4493        // nowhere. An exact match is still checked against every state, since
4494        // that is a regression of one specific issue rather than a resemblance
4495        // to one, and a tracker still searches closed issues because a
4496        // checklist item matching one is a done item that wants a link.
4497        let state = if include_closed { "all" } else { "open" };
4498        let text = self.gh(&[
4499            "issue",
4500            "list",
4501            "--state",
4502            state,
4503            "--limit",
4504            "100",
4505            "--search",
4506            query.trim(),
4507            "--json",
4508            "number,title,url,body,state,stateReason",
4509        ])?;
4510        let rows: Vec<Row> = serde_json::from_str(text.trim())
4511            .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
4512
4513        Ok(rows
4514            .into_iter()
4515            .filter(|row| Some(row.number) != apart_from)
4516            .find(|row| {
4517                row.title.trim().eq_ignore_ascii_case(title.trim())
4518                    || textsim::covers(title, body, &row.title, &row.body)
4519            })
4520            .map(|row| ExistingIssue {
4521                number: row.number,
4522                url: row.url,
4523                title: row.title,
4524                open: row.state.eq_ignore_ascii_case("open"),
4525                body: row.body,
4526            }))
4527    }
4528
4529    /// Avoid filing a duplicate when a follow-up already exists.
4530    pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
4531        #[derive(Deserialize)]
4532        struct Row {
4533            title: String,
4534            url: String,
4535        }
4536        let needle = title.trim().to_lowercase();
4537        if needle.is_empty() {
4538            return None;
4539        }
4540        // Quotes and newlines would be read as search syntax rather than text.
4541        let query: String = title
4542            .chars()
4543            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4544            .take(120)
4545            .collect();
4546        let text = self.gh_try(&[
4547            "issue",
4548            "list",
4549            "--state",
4550            "all",
4551            "--limit",
4552            "100",
4553            "--search",
4554            query.trim(),
4555            "--json",
4556            "number,title,url",
4557        ]);
4558        serde_json::from_str::<Vec<Row>>(text.trim())
4559            .ok()?
4560            .into_iter()
4561            .find(|row| row.title.trim().to_lowercase() == needle)
4562            .map(|row| row.url)
4563    }
4564
4565    /// Squash merge, tolerating cleanup failures after a successful merge.
4566    ///
4567    /// Take a pull request out of draft, once the review has converged.
4568    ///
4569    /// Best effort for the remaining workflow. A failure does not discard the
4570    /// review or stop later independent work, but the final write summary
4571    /// reports it and the command returns non-zero.
4572    pub fn mark_ready(&self, number: i64) -> bool {
4573        match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
4574            Ok(_) => true,
4575            Err(e) => {
4576                logdim!(
4577                    "PR #{number} is approved but could not be taken out of draft: {}",
4578                    e.last_line()
4579                );
4580                false
4581            }
4582        }
4583    }
4584
4585    /// The worktree that has `branch` checked out, if one does.
4586    fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
4587        let listed = self
4588            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
4589            .ok()?;
4590        let wanted = format!("branch refs/heads/{branch}");
4591        let mut current: Option<PathBuf> = None;
4592        for line in listed.lines() {
4593            if let Some(path) = line.strip_prefix("worktree ") {
4594                current = Some(PathBuf::from(path));
4595            } else if line == wanted {
4596                return current;
4597            }
4598        }
4599        None
4600    }
4601
4602    /// Finish the local cleanup a merge could not.
4603    ///
4604    /// `gh pr merge --delete-branch` gives up on the local branch whenever any
4605    /// worktree has it checked out, which is the ordinary state rather than an
4606    /// odd one: `spar run` opens the pull request from
4607    /// `.spar-worktrees/issue-N` and leaves the checkout there, and the merge
4608    /// usually happens in a later `spar resume` working somewhere else. The
4609    /// next run's sweep tidied it, so nothing was ever lost, but the run that
4610    /// did the merge reported a failure about a branch nobody will touch again
4611    /// and left a checkout for the next run to explain.
4612    ///
4613    /// Held to the same rules the sweep uses, because they are the rules that
4614    /// decide whether anything would be lost: a branch spar recorded creating,
4615    /// a worktree of spar's own with no recoverable work in it, and a tip some
4616    /// surviving ref preserves. Anything else is left exactly where it is.
4617    fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
4618        let branch = self.pr_view(number).ok()?.head_ref_name;
4619        if branch.is_empty() || !self.known_branches().contains_key(&branch) {
4620            return None;
4621        }
4622        let mut released = None;
4623        if let Some(path) = self.worktree_holding(&branch) {
4624            if !path.starts_with(self.root.join(WORKTREE_DIR)) {
4625                return None;
4626            }
4627            if !matches!(
4628                self.has_recoverable_work(&path, Disposable::BuildOutput),
4629                Ok(false)
4630            ) {
4631                return None;
4632            }
4633            match self.remove_worktree_at(&path) {
4634                Ok(true) => released = Some(path),
4635                _ => return None,
4636            }
4637        }
4638        match self.delete_branch_if_safe(&branch) {
4639            Ok(true) => {
4640                self.forget_branch(&branch);
4641                Some(match released {
4642                    Some(path) => format!(
4643                        " The worktree at {} was released and branch {branch} deleted.",
4644                        path.display()
4645                    ),
4646                    None => format!(" Branch {branch} was deleted."),
4647                })
4648            }
4649            _ => released.map(|path| {
4650                format!(
4651                    " The worktree at {} was released, but branch {branch} was kept.",
4652                    path.display()
4653                )
4654            }),
4655        }
4656    }
4657
4658    /// `gh pr merge --delete-branch` exits non-zero when it cannot delete the
4659    /// local branch, which happens *after* the merge has already landed.
4660    /// Treating that as a failure reports work as lost when it is not.
4661    pub fn merge_pr(&self, number: i64) -> Result<()> {
4662        let n = number.to_string();
4663        let merged = match self.gh(&merge_pr_args(&n, None, true)) {
4664            Ok(_) => Ok(()),
4665            Err(e) => {
4666                if self.pr_state(number) == "MERGED" {
4667                    let finished = self
4668                        .finish_merged_branch_cleanup(number)
4669                        .unwrap_or_default();
4670                    logdim!(
4671                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
4672                        e.last_line()
4673                    );
4674                    Ok(())
4675                } else {
4676                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4677                }
4678            }
4679        };
4680        self.record_write(merged)
4681    }
4682
4683    /// Squash merge only if the pull request still exposes the reviewed head.
4684    pub fn merge_pr_at_head(
4685        &self,
4686        number: i64,
4687        expected_head: &str,
4688        delete_branch: bool,
4689    ) -> Result<()> {
4690        let n = number.to_string();
4691        let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
4692            Ok(_) => Ok(()),
4693            Err(e) => {
4694                if self.pr_state(number) == "MERGED" {
4695                    let finished = self
4696                        .finish_merged_branch_cleanup(number)
4697                        .unwrap_or_default();
4698                    logdim!(
4699                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
4700                        e.last_line()
4701                    );
4702                    Ok(())
4703                } else {
4704                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4705                }
4706            }
4707        };
4708        self.record_write(merged)
4709    }
4710
4711    // -- follow-ups -------------------------------------------------------
4712
4713    /// The queue of follow-ups recorded locally rather than filed, which
4714    /// `spar followup` works.
4715    pub fn followups_path(&self) -> PathBuf {
4716        self.root.join(STATE_DIR).join("followups.md")
4717    }
4718
4719    /// Where `spar brainstorm` writes a session unless told otherwise.
4720    ///
4721    /// Under the state directory, so it is kept out of the repository's own
4722    /// `git status`; `--out` puts one somewhere a person would commit.
4723    pub fn brainstorms_dir(&self) -> PathBuf {
4724        self.root.join(STATE_DIR).join("brainstorms")
4725    }
4726
4727    /// What `spar followup` already dealt with, kept beside the queue.
4728    ///
4729    /// Two jobs. It is what stops `append_local_followup` re-recording a
4730    /// follow-up whose entry has since left the queue, which would otherwise
4731    /// turn the file into a ring buffer of things already filed. And it keeps
4732    /// the text of an entry a screening pass ruled stale, so a wrong verdict
4733    /// costs a re-read rather than the only copy of a real defect.
4734    pub fn worked_followups_path(&self) -> PathBuf {
4735        self.root.join(STATE_DIR).join("followups.done.md")
4736    }
4737
4738    /// What `spar checkin` has already answered on one pull request or issue.
4739    /// Where what a run spent is kept.
4740    pub fn spend_path(&self) -> PathBuf {
4741        self.root.join(STATE_DIR).join("state").join("spend.json")
4742    }
4743
4744    /// Append this run's calls to the record.
4745    ///
4746    /// Appended rather than replaced, so a resumed pull request adds to the
4747    /// count of what it has cost so far rather than starting it again, and so
4748    /// anybody charting this has a file to chart.
4749    pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
4750        if calls.is_empty() {
4751            return Ok(());
4752        }
4753        let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
4754            .ok()
4755            .and_then(|text| serde_json::from_str(&text).ok())
4756            .unwrap_or_default();
4757        all.extend(calls.iter().cloned());
4758        if let Some(parent) = self.spend_path().parent() {
4759            let _ = std::fs::create_dir_all(parent);
4760        }
4761        write_json_atomic(&self.spend_path(), &all)
4762    }
4763
4764    /// Who wrote the first draft of each issue, by number.
4765    ///
4766    /// Custody alternates within a pull request and the record of it lives in
4767    /// that pull request's state. This is the other question, which nothing
4768    /// answered: over a queue, which model authored what.
4769    pub fn implementors_path(&self) -> PathBuf {
4770        self.root
4771            .join(STATE_DIR)
4772            .join("state")
4773            .join("implementors.json")
4774    }
4775
4776    pub fn read_implementors(&self) -> BTreeMap<i64, String> {
4777        std::fs::read_to_string(self.implementors_path())
4778            .ok()
4779            .and_then(|text| serde_json::from_str(&text).ok())
4780            .unwrap_or_default()
4781    }
4782
4783    /// Best effort: this is a record for a person, not something the loop reads
4784    /// back, so a failure to write it is not a reason to stop.
4785    pub fn remember_implementor(&self, issue: i64, agent: &str) {
4786        let mut known = self.read_implementors();
4787        known.insert(issue, agent.to_string());
4788        if let Some(parent) = self.implementors_path().parent() {
4789            let _ = std::fs::create_dir_all(parent);
4790        }
4791        if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
4792            logdim!("could not record who implemented #{issue}: {e}");
4793        }
4794    }
4795
4796    /// Where the issues triage disagreed about are remembered.
4797    ///
4798    /// One file rather than one per issue: it is read once per run, before any
4799    /// triage call, to decide what not to ask about again.
4800    pub fn contested_path(&self) -> PathBuf {
4801        self.root
4802            .join(STATE_DIR)
4803            .join("state")
4804            .join("contested.json")
4805    }
4806
4807    /// The issues a person has been asked to decide, by number.
4808    pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
4809        std::fs::read_to_string(self.contested_path())
4810            .ok()
4811            .and_then(|text| serde_json::from_str(&text).ok())
4812            .unwrap_or_default()
4813    }
4814
4815    /// Remember an issue triage could not agree on.
4816    ///
4817    /// Best effort: a run that cannot write this asks again next time, which is
4818    /// what it did before this existed.
4819    pub fn remember_contested(&self, items: &[ContestedItem]) {
4820        if items.is_empty() {
4821            return;
4822        }
4823        let mut known = self.read_contested();
4824        for item in items {
4825            known.insert(
4826                item.issue,
4827                Contested {
4828                    title: item.title.clone(),
4829                    positions: item.positions.clone(),
4830                    reasons: item.reasons.clone(),
4831                },
4832            );
4833        }
4834        if let Some(parent) = self.contested_path().parent() {
4835            let _ = std::fs::create_dir_all(parent);
4836        }
4837        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4838            logdim!("could not record the contested issues: {e}");
4839        }
4840    }
4841
4842    /// Ask about them again, because a person has acted on them.
4843    pub fn forget_contested(&self, numbers: &[i64]) {
4844        let mut known = self.read_contested();
4845        let before = known.len();
4846        known.retain(|number, _| !numbers.contains(number));
4847        if known.len() == before {
4848            return;
4849        }
4850        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4851            logdim!("could not update the contested issues: {e}");
4852        }
4853    }
4854
4855    pub fn checkin_state_path(&self, number: i64) -> PathBuf {
4856        self.root
4857            .join(STATE_DIR)
4858            .join("state")
4859            .join(format!("checkin-{number}.json"))
4860    }
4861
4862    /// Where `spar review` records what it reviewed, and a second look reads it.
4863    pub fn review_record_path(&self, number: i64) -> PathBuf {
4864        self.root
4865            .join(STATE_DIR)
4866            .join("state")
4867            .join(format!("review-{number}.json"))
4868    }
4869
4870    /// None when there is no record, or when it cannot be read. A record spar
4871    /// cannot read is one it reviews afresh over, and the log says so.
4872    pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
4873        let path = self.review_record_path(number);
4874        let text = std::fs::read_to_string(&path).ok()?;
4875        match serde_json::from_str(&text) {
4876            Ok(record) => Some(record),
4877            Err(e) => {
4878                logdim!("could not read {}: {e}", path.display());
4879                None
4880            }
4881        }
4882    }
4883
4884    pub fn write_review_record(
4885        &self,
4886        number: i64,
4887        record: &crate::model::ReviewRecord,
4888    ) -> Result<()> {
4889        write_json_atomic(&self.review_record_path(number), record)
4890    }
4891
4892    /// Append a follow-up to a local note instead of the tracker.
4893    ///
4894    /// Deduplicated on the title, matching the issue path. The body arrives
4895    /// with its provenance already stamped by the caller, so nothing is added
4896    /// here.
4897    ///
4898    /// A write that did not happen is reported as such rather than as a
4899    /// duplicate: the caller settles the point on the strength of this answer,
4900    /// and settling it on a failed write is how a real defect is lost.
4901    ///
4902    /// Both files are checked, because `spar followup` removes an entry from
4903    /// the queue once it has filed it. Checking only the queue would let the
4904    /// next run that rediscovers the same defect append it again, on top of the
4905    /// issue that now exists for it.
4906    pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
4907        let path = self.followups_path();
4908        let heading = format!("## {}", title.trim());
4909        for seen in [&path, &self.worked_followups_path()] {
4910            if let Ok(existing) = std::fs::read_to_string(seen) {
4911                if has_heading(&existing, title) {
4912                    logdim!("follow-up already noted: {title}");
4913                    return Followup::Covered(format!("note: {}", title.trim()));
4914                }
4915            }
4916        }
4917        if let Some(parent) = path.parent() {
4918            let _ = std::fs::create_dir_all(parent);
4919        }
4920        use std::io::Write;
4921        // The caller already stamped the provenance into the body. Adding
4922        // "From #N." here as well printed it twice, in two different wordings.
4923        //
4924        // The marker above the heading is what makes the entry boundary
4925        // unambiguous to the parser, since the body's own sections are written
4926        // at the same heading level as the title.
4927        let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
4928        match std::fs::OpenOptions::new()
4929            .create(true)
4930            .append(true)
4931            .open(&path)
4932        {
4933            Ok(mut file) => match file.write_all(entry.as_bytes()) {
4934                Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
4935                Err(e) => {
4936                    logdim!("could not write {}: {e}", path.display());
4937                    Followup::Failed
4938                }
4939            },
4940            Err(e) => {
4941                logdim!("could not write {}: {e}", path.display());
4942                Followup::Failed
4943            }
4944        }
4945    }
4946
4947    /// Record what `spar followup` did with an entry, and why.
4948    ///
4949    /// Best effort: an archive that could not be written is not a reason to
4950    /// stop, since the entry has already been filed or ruled on.
4951    pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
4952        let path = self.worked_followups_path();
4953        if let Some(parent) = path.parent() {
4954            let _ = std::fs::create_dir_all(parent);
4955        }
4956        use std::io::Write;
4957        let entry = format!(
4958            "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
4959            title.trim(),
4960            body.trim()
4961        );
4962        if let Ok(mut file) = std::fs::OpenOptions::new()
4963            .create(true)
4964            .append(true)
4965            .open(&path)
4966        {
4967            let _ = file.write_all(entry.as_bytes());
4968        }
4969    }
4970
4971    // -- resumable state --------------------------------------------------
4972    //
4973    // Custody cannot be read from GitHub authorship: every agent commits and
4974    // comments as the same git identity, so `author` is always the human who
4975    // ran spar. State is kept on disk by default and can additionally travel in
4976    // a PR comment, which is what lets a run be resumed from another machine.
4977
4978    /// Where a comment spar produced but did not post is kept.
4979    pub fn pending_comment_path(&self, number: i64) -> PathBuf {
4980        self.root
4981            .join(STATE_DIR)
4982            .join("reviews")
4983            .join(format!("pr-{number}.md"))
4984    }
4985
4986    /// Keep a comment spar decided not to post.
4987    ///
4988    /// A dry run that prints and forgets means agreeing with what you read
4989    /// costs a second full review. Saving it makes the whole point of reading
4990    /// it first: look, edit if you like, then post what you already paid for.
4991    pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
4992        let path = self.pending_comment_path(number);
4993        if let Some(parent) = path.parent() {
4994            std::fs::create_dir_all(parent)
4995                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
4996        }
4997        std::fs::write(&path, text)
4998            .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4999        Ok(path)
5000    }
5001
5002    pub fn read_pending_comment(&self, number: i64) -> Option<String> {
5003        std::fs::read_to_string(self.pending_comment_path(number)).ok()
5004    }
5005
5006    pub fn state_path(&self, number: i64) -> PathBuf {
5007        self.root
5008            .join(STATE_DIR)
5009            .join("state")
5010            .join(format!("pr-{number}.json"))
5011    }
5012
5013    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
5014        let path = self.state_path(number);
5015        let text = std::fs::read_to_string(&path).ok()?;
5016        match serde_json::from_str(&text) {
5017            Ok(state) => Some(state),
5018            Err(_) => {
5019                logdim!("could not read {}, starting fresh", path.display());
5020                None
5021            }
5022        }
5023    }
5024
5025    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
5026        if let Some(local) = self.read_local_state(pr.number) {
5027            return Some(local);
5028        }
5029        if self.state_store.writes_pr() {
5030            return self.read_pr_state(pr.number);
5031        }
5032        None
5033    }
5034
5035    pub(crate) fn read_state_for_head(
5036        &self,
5037        pr: &PrView,
5038        actual_head: &str,
5039    ) -> Option<PersistedState> {
5040        let local = self
5041            .state_store
5042            .writes_local()
5043            .then(|| self.read_local_state(pr.number))
5044            .flatten();
5045        let remote = self
5046            .state_store
5047            .writes_pr()
5048            .then(|| self.read_pr_state(pr.number))
5049            .flatten();
5050        let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
5051        if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
5052            self.remember_checkpoint(pr.number, checkpoint);
5053        }
5054        choose_state_for_head(candidates, actual_head)
5055    }
5056
5057    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
5058        self.try_read_pr_state(number).ok().flatten()
5059    }
5060
5061    fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
5062        for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
5063            if let Some(state) = parse_state_comment(&body) {
5064                return Ok(Some(state));
5065            }
5066        }
5067        Ok(None)
5068    }
5069
5070    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5071        let remote_state = if self.state_store.writes_pr() {
5072            self.try_read_pr_state(number)
5073        } else {
5074            Ok(None)
5075        };
5076        self.write_state_after_remote_read(number, state, remote_state)
5077    }
5078
5079    fn write_state_after_remote_read(
5080        &self,
5081        number: i64,
5082        state: &PersistedState,
5083        remote_state: Result<Option<PersistedState>>,
5084    ) -> Result<()> {
5085        let remote_checkpoint = if self.state_store.writes_pr() {
5086            self.record_failed_write(remote_state)?
5087                .map(|saved| saved.checkpoint)
5088                .unwrap_or_default()
5089        } else {
5090            0
5091        };
5092        let local_checkpoint = self
5093            .state_store
5094            .writes_local()
5095            .then(|| self.read_local_state(number))
5096            .flatten()
5097            .map(|saved| saved.checkpoint)
5098            .unwrap_or_default();
5099        let mut stamped = state.clone();
5100        stamped.checkpoint = state
5101            .checkpoint
5102            .max(local_checkpoint)
5103            .max(remote_checkpoint)
5104            .max(self.remembered_checkpoint(number))
5105            .saturating_add(1);
5106        self.remember_checkpoint(number, stamped.checkpoint);
5107        if self.state_store.writes_local() {
5108            write_json_atomic(&self.state_path(number), &stamped)?;
5109        }
5110        if self.state_store.writes_pr() {
5111            self.write_pr_state(number, &stamped)?;
5112        }
5113        Ok(())
5114    }
5115
5116    fn remembered_checkpoint(&self, number: i64) -> u64 {
5117        self.checkpoints
5118            .lock()
5119            .unwrap_or_else(std::sync::PoisonError::into_inner)
5120            .get(&number)
5121            .copied()
5122            .unwrap_or_default()
5123    }
5124
5125    fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
5126        let mut checkpoints = self
5127            .checkpoints
5128            .lock()
5129            .unwrap_or_else(std::sync::PoisonError::into_inner);
5130        let saved = checkpoints.entry(number).or_default();
5131        *saved = (*saved).max(checkpoint);
5132    }
5133
5134    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5135        // Not run through clean(): this is structured data, and scrubbing would
5136        // corrupt refutation text stored in the ledger. It sits inside an
5137        // unclosed HTML comment so GitHub renders it as nothing.
5138        let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
5139        let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
5140        let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
5141        if let Some(id) = comment_id {
5142            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
5143            let field = format!("body={body}");
5144            let written = self
5145                .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
5146                .map(|_| ());
5147            return self.record_write(written);
5148        }
5149        let written = self
5150            .gh(&["pr", "comment", &number.to_string(), "--body", &body])
5151            .map(|_| ());
5152        self.record_write(written)
5153    }
5154
5155    /// Top level comments. Works for issues and pull requests alike, because
5156    /// GitHub serves both from the issues endpoint.
5157    ///
5158    /// Nothing when they cannot be read, which suits a reader that is going to
5159    /// go on regardless. A caller deciding whether it has already written here
5160    /// wants `try_issue_comments`, since for that one no comments and no answer
5161    /// are opposite answers.
5162    pub fn issue_comments(&self, number: i64) -> Vec<Value> {
5163        self.try_issue_comments(number).unwrap_or_default()
5164    }
5165
5166    pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
5167        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
5168        try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
5169    }
5170
5171    /// spar's own state comments on a pull request, newest last.
5172    ///
5173    /// Three tests, because the answer decides what gets patched over and what
5174    /// gets deleted. A comment matching the marker loosely, which is what
5175    /// somebody asking "why is there a `<!-- spar:state` block on this PR?"
5176    /// writes, is named in the log and left exactly where it is.
5177    fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
5178        let viewer = self.viewer_login()?.to_string();
5179        let mut mine = Vec::new();
5180        let mut theirs = Vec::new();
5181        for comment in self.try_issue_comments(number)? {
5182            let Some(body) = comment.get("body").and_then(Value::as_str) else {
5183                continue;
5184            };
5185            if !body.contains("spar:state") {
5186                continue;
5187            }
5188            let Some(id) = comment.get("id").and_then(Value::as_i64) else {
5189                continue;
5190            };
5191            let author = comment
5192                .get("user")
5193                .and_then(|u| u.get("login"))
5194                .and_then(Value::as_str)
5195                .unwrap_or_default();
5196            if is_state_comment(body, author, &viewer) {
5197                mine.push((id, body.to_string()));
5198            } else {
5199                theirs.push(format!("@{author}"));
5200            }
5201        }
5202        if !theirs.is_empty() {
5203            logdim!(
5204                "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
5205                 own state block is patched or deleted.",
5206                theirs.len(),
5207                theirs.join(", ")
5208            );
5209        }
5210        Ok(mine)
5211    }
5212
5213    fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
5214        Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
5215    }
5216
5217    /// Drop state once the PR is finished and there is nothing to resume.
5218    pub fn clear_state(&self, number: i64) {
5219        let path = self.state_path(number);
5220        let _ = std::fs::remove_file(&path);
5221        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
5222    }
5223
5224    // -- housekeeping -----------------------------------------------------
5225
5226    /// Remove state files whose PR is merged or closed: the custody loop's
5227    /// state, the review record, and the check-in watermark alike. Grouped by
5228    /// number so each finished pull request costs one `gh` read however many
5229    /// files it left.
5230    pub fn prune_state(&self) -> Vec<String> {
5231        let base = self.root.join(STATE_DIR).join("state");
5232        let Ok(entries) = std::fs::read_dir(&base) else {
5233            return Vec::new();
5234        };
5235        let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
5236        for name in entries
5237            .flatten()
5238            .filter_map(|e| e.file_name().to_str().map(str::to_string))
5239        {
5240            if let Some(number) = state_file_number(&name) {
5241                by_number.entry(number).or_default().push(name);
5242            }
5243        }
5244
5245        let mut removed = Vec::new();
5246        for (number, mut names) in by_number {
5247            if !is_finished(&self.pr_state(number)) {
5248                continue;
5249            }
5250            names.sort();
5251            for name in names {
5252                let _ = std::fs::remove_file(base.join(&name));
5253                removed.push(format!("state {name}"));
5254            }
5255        }
5256        removed
5257    }
5258
5259    /// Delete state comments from PRs that are finished.
5260    ///
5261    /// Open PRs are left alone: their state may still be live.
5262    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
5263        #[derive(Deserialize)]
5264        struct Row {
5265            number: i64,
5266        }
5267        let numbers = match numbers {
5268            Some(numbers) => numbers,
5269            None => {
5270                let listed: Result<Vec<i64>> = (|| {
5271                    let text = self.gh(&[
5272                        "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
5273                    ])?;
5274                    let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5275                        .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
5276                    Ok(rows.into_iter().map(|row| row.number).collect())
5277                })();
5278                match self.record_failed_write(listed) {
5279                    Ok(numbers) => numbers,
5280                    Err(e) => {
5281                        logdim!("could not inspect pull requests for state cleanup: {e}");
5282                        return Vec::new();
5283                    }
5284                }
5285            }
5286        };
5287
5288        let mut removed = Vec::new();
5289        for number in numbers {
5290            let state = match self.record_failed_write(self.try_pr_state(number)) {
5291                Ok(state) => state,
5292                Err(e) => {
5293                    logdim!("could not inspect PR #{number} for state cleanup: {e}");
5294                    continue;
5295                }
5296            };
5297            if !is_finished(&state) {
5298                continue;
5299            }
5300            let comments = match self.record_failed_write(self.try_state_comments(number)) {
5301                Ok(comments) => comments,
5302                Err(e) => {
5303                    logdim!("could not inspect state comments on PR #{number}: {e}");
5304                    continue;
5305                }
5306            };
5307            for (id, _) in comments {
5308                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
5309                let deleted = self
5310                    .gh(&["api", "-X", "DELETE", &path, "--silent"])
5311                    .map(|_| ());
5312                match self.record_write(deleted) {
5313                    Ok(()) => removed.push(format!("state comment on PR #{number}")),
5314                    Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
5315                }
5316            }
5317        }
5318        removed
5319    }
5320
5321    /// Drop worktrees whose PR is finished, then the branches they left behind.
5322    ///
5323    /// With auto_merge off, which is the default, a run ends at "approved", so
5324    /// nothing would ever clean these up on its own and they accumulate one per
5325    /// run. A stranded worktree also holds its branch checked out, which makes
5326    /// a later `gh pr merge --delete-branch` fail to clean up.
5327    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
5328        let base = self.root.join(WORKTREE_DIR);
5329        let mut removed = Vec::new();
5330        let known = self.known_branches();
5331
5332        if let Ok(entries) = std::fs::read_dir(&base) {
5333            let mut names: Vec<String> = entries
5334                .flatten()
5335                .filter(|e| e.path().is_dir())
5336                .filter_map(|e| e.file_name().to_str().map(str::to_string))
5337                .collect();
5338            names.sort();
5339
5340            for name in names {
5341                // A review worktree is detached and owns no branch, so it is
5342                // tied to the pull request only by its directory name.
5343                if let Some(rest) = name.strip_prefix("review-") {
5344                    let number: i64 = rest.parse().unwrap_or(-1);
5345                    if !(force_all || is_finished(&self.pr_state(number))) {
5346                        continue;
5347                    }
5348                    let path = base.join(&name);
5349                    if force_all {
5350                        let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
5351                            if !belongs {
5352                                return Ok(false);
5353                            }
5354                            let local_ref = review_ref(number);
5355                            if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
5356                                return Ok(false);
5357                            }
5358                            let head = self.head_oid_checked(&path)?;
5359                            let recorded = self
5360                                .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
5361                                .trim()
5362                                .to_string();
5363                            Ok(head == recorded)
5364                        });
5365                        match owned {
5366                            Ok(true) => {}
5367                            Ok(false) => {
5368                                logdim!(
5369                                    "kept {} because no matching SPAR review reference proves \
5370                                     ownership",
5371                                    path.display()
5372                                );
5373                                continue;
5374                            }
5375                            Err(e) => {
5376                                logdim!(
5377                                    "kept {} because review ownership could not be verified: {}",
5378                                    path.display(),
5379                                    e.last_line()
5380                                );
5381                                continue;
5382                            }
5383                        }
5384                    } else {
5385                        if let Err(e) = self.refuse_review_worktree_changes(number) {
5386                            logdim!(
5387                                "kept {} because its review state could not be verified as \
5388                                 disposable: {}",
5389                                path.display(),
5390                                e.last_line()
5391                            );
5392                            continue;
5393                        }
5394                    }
5395                    if force_all {
5396                        if self.remove_worktree_at_force(&path) {
5397                            self.git_try(&["update-ref", "-d", &review_ref(number)]);
5398                        }
5399                    } else {
5400                        self.release_review_worktree(number);
5401                    }
5402                    if !path.exists() {
5403                        removed.push(name);
5404                    }
5405                    continue;
5406                }
5407                let branch = format!("{}{name}", self.branch_prefix);
5408                if !(force_all || self.worktree_is_done(&branch)) {
5409                    continue;
5410                }
5411                if !known.contains_key(&branch) {
5412                    logdim!("kept {branch} because it has no branch record");
5413                    continue;
5414                }
5415                let path = base.join(&name);
5416                if !force_all {
5417                    match self.has_recoverable_work(&path, Disposable::BuildOutput) {
5418                        Ok(true) => {
5419                            logdim!(
5420                                "kept {} because it contains uncommitted changes or ignored files",
5421                                path.display()
5422                            );
5423                            continue;
5424                        }
5425                        Err(e) => {
5426                            logdim!(
5427                                "kept {} because its Git state could not be checked: {}",
5428                                path.display(),
5429                                e.last_line()
5430                            );
5431                            continue;
5432                        }
5433                        Ok(false) => {}
5434                    }
5435                    match self.branch_deletion_is_safe(&branch) {
5436                        Ok(true) => {}
5437                        Ok(false) => {
5438                            logdim!(
5439                                "kept {branch} because no surviving ref preserves its tip or \
5440                                 reflog-only commits"
5441                            );
5442                            continue;
5443                        }
5444                        Err(e) => {
5445                            logdim!(
5446                                "kept {branch} because preservation could not be verified: {}",
5447                                e.last_line()
5448                            );
5449                            continue;
5450                        }
5451                    }
5452                }
5453                let removed_worktree = if force_all {
5454                    self.remove_worktree_at_force(&path)
5455                } else {
5456                    match self.remove_worktree_at(&path) {
5457                        Ok(removed) => removed,
5458                        Err(error) => {
5459                            logdim!(
5460                                "kept {branch} and {} because removal did not reach a confirmed \
5461                                 quiet point: {}",
5462                                path.display(),
5463                                error.last_line()
5464                            );
5465                            false
5466                        }
5467                    }
5468                };
5469                if !removed_worktree {
5470                    continue;
5471                }
5472                if force_all {
5473                    self.git_try(&["branch", "-D", &branch]);
5474                    self.forget_branch(&branch);
5475                } else {
5476                    match self.delete_branch_if_safe(&branch) {
5477                        Ok(true) => self.forget_branch(&branch),
5478                        Ok(false) => logdim!(
5479                            "kept {branch} because its tip or reflog changed before deletion"
5480                        ),
5481                        Err(error) => logdim!(
5482                            "kept {branch} because deletion safety could not be rechecked: {}",
5483                            error.last_line()
5484                        ),
5485                    }
5486                }
5487                removed.push(name);
5488            }
5489        }
5490        removed.extend(self.prune_branches(force_all));
5491        removed
5492    }
5493
5494    /// Delete leftover branches spar created whose worktree is already gone.
5495    ///
5496    /// Deletion is driven by the ledger of branches spar actually created, not
5497    /// by a name pattern. Names default to `issue-N`, which is exactly what a
5498    /// person would call a branch themselves, so a name alone can never
5499    /// establish ownership. This is the data loss guard.
5500    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
5501        let known = self.known_branches();
5502        let branches: Vec<String> = known.keys().cloned().collect();
5503        if branches.is_empty() {
5504            return Vec::new();
5505        }
5506
5507        let checked_out: Vec<String> = self
5508            .git_try(&["worktree", "list", "--porcelain"])
5509            .lines()
5510            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
5511            .collect();
5512
5513        // %(refname:short) is ambiguous when a tag shares the branch name (it
5514        // yields "heads/..."), so take the full ref and strip it here.
5515        let existing: Vec<String> = self
5516            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
5517            .lines()
5518            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
5519            .collect();
5520
5521        let mut removed = Vec::new();
5522        for branch in branches {
5523            if !existing.contains(&branch) {
5524                self.forget_branch(&branch); // already gone, drop the record
5525                continue;
5526            }
5527            if checked_out.contains(&branch) {
5528                continue;
5529            }
5530            if !(force_all || self.worktree_is_done(&branch)) {
5531                continue;
5532            }
5533            if !force_all {
5534                let Some(_record) = known.get(&branch) else {
5535                    continue;
5536                };
5537                match self.branch_deletion_is_safe(&branch) {
5538                    Ok(true) => {}
5539                    Ok(false) => {
5540                        logdim!(
5541                            "kept {branch} because no surviving ref preserves its tip or \
5542                             reflog-only commits"
5543                        );
5544                        continue;
5545                    }
5546                    Err(e) => {
5547                        logdim!(
5548                            "kept {branch} because preservation could not be verified: {}",
5549                            e.last_line()
5550                        );
5551                        continue;
5552                    }
5553                }
5554            }
5555            let deleted = if force_all {
5556                self.git(&["branch", "-D", &branch]).map(|_| true)
5557            } else {
5558                self.delete_branch_if_safe(&branch)
5559            };
5560            match deleted {
5561                Ok(true) => {
5562                    self.forget_branch(&branch);
5563                    removed.push(format!("branch {branch}"));
5564                }
5565                Ok(false) => {
5566                    logdim!("kept {branch} because its tip or reflog changed before deletion");
5567                }
5568                Err(e) => {
5569                    // A branch that silently survives pruning looks like a spar
5570                    // bug, so the name and git's own reason have to be said.
5571                    logdim!("could not delete {branch}: {}", e.last_line());
5572                }
5573            }
5574        }
5575        removed
5576    }
5577
5578    /// True when the PR behind this branch is merged or closed.
5579    fn worktree_is_done(&self, branch: &str) -> bool {
5580        #[derive(Deserialize)]
5581        struct Row {
5582            state: String,
5583        }
5584        let entry = branch
5585            .strip_prefix(self.branch_prefix.as_str())
5586            .unwrap_or(branch);
5587        if let Some(rest) = entry.strip_prefix("pr-") {
5588            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
5589        }
5590        // A split part is the same shape as an issue branch: one branch, whose
5591        // pull requests say whether it is finished. Without it here, a part
5592        // branch is one nothing but `clean --all` would ever remove.
5593        if entry.starts_with("issue-") || entry.starts_with("split-") {
5594            let text = self.gh_try(&[
5595                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
5596            ]);
5597            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
5598            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
5599        }
5600        false
5601    }
5602}
5603
5604// ---------------------------------------------------------------------------
5605// Free helpers
5606// ---------------------------------------------------------------------------
5607
5608/// Take a read-only probe again before treating its failure as evidence.
5609///
5610/// Every probe is a Git invocation under a thirty second cap plus a walk over a
5611/// tree an agent may still be building in. A probe that fails proves nothing
5612/// about the worktree, and there is a real difference between "the tree moved"
5613/// and "the question could not be asked just then", which the caller turns into
5614/// the same run-ending error.
5615pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
5616    match probe() {
5617        Ok(value) => Ok(value),
5618        Err(first) => {
5619            logdim!("retrying a worktree probe after: {}", first.last_line());
5620            probe()
5621        }
5622    }
5623}
5624
5625/// Read attribute files without asking Git to inspect working-tree content.
5626///
5627/// A newly written attribute can select a clean or smudge filter. It must be
5628/// detected before a post-call status, diff, or add command has a chance to run
5629/// that filter in the parent process.
5630pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
5631    let root = std::fs::canonicalize(cwd)
5632        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5633    let mut files = BTreeMap::new();
5634    let mut visited = BTreeSet::new();
5635    collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
5636    Ok(AttributeState { files })
5637}
5638
5639fn collect_attribute_files(
5640    root: &Path,
5641    repository: &Path,
5642    prefix: &Path,
5643    visited: &mut BTreeSet<PathBuf>,
5644    files: &mut BTreeMap<PathBuf, [u8; 32]>,
5645) -> Result<()> {
5646    let canonical = std::fs::canonicalize(repository)
5647        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5648    if !visited.insert(canonical) {
5649        bail!("submodule recursion revisited {}", repository.display());
5650    }
5651    let entries = index_entries(repository)?;
5652    let mut paths: BTreeSet<PathBuf> = entries
5653        .iter()
5654        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5655        .map(|entry| entry.path.clone())
5656        .collect();
5657    // Ignored on purpose. A `.gitattributes` under build output can only select
5658    // filters for paths that are themselves ignored, and no ignored path is
5659    // ever staged or compared as tracked content, so it cannot reach a managed
5660    // commit. Capturing it meant `cargo package` or `npm ci` writing one into
5661    // its own output ended a read-only call outright.
5662    paths.extend(untracked_attribute_paths(repository)?);
5663    for path in paths {
5664        let from_root = prefix.join(&path);
5665        let state = attribute_file_fingerprint(&root.join(&from_root))?;
5666        files.insert(from_root, state);
5667    }
5668    for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
5669        let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
5670            continue;
5671        };
5672        collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
5673    }
5674    Ok(())
5675}
5676
5677/// Leave a visible, untracked reason ordinary cleanup can detect on a later
5678/// run even when Git status normalizes the original working-file change away.
5679pub(crate) fn uncertain_worktree_change(
5680    cwd: &Path,
5681    message: impl Into<String>,
5682) -> crate::error::SparError {
5683    let message = message.into();
5684    let marker = write_recovery_marker(cwd, &message);
5685    let note = match marker {
5686        Ok(path) => format!(" Recovery marker: {}.", path.display()),
5687        Err(e) => format!(
5688            " A recovery marker could not be written: {}.",
5689            e.last_line()
5690        ),
5691    };
5692    crate::error::SparError::uncertain_write(format!("{message}{note}"))
5693}
5694
5695fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
5696    use std::sync::atomic::{AtomicU32, Ordering};
5697    static NEXT: AtomicU32 = AtomicU32::new(0);
5698    for _ in 0..1000 {
5699        let serial = NEXT.fetch_add(1, Ordering::Relaxed);
5700        let path = cwd.join(format!(
5701            "{RECOVERY_MARKER_PREFIX}{}-{serial}",
5702            std::process::id()
5703        ));
5704        let mut options = OpenOptions::new();
5705        options.write(true).create_new(true);
5706        #[cfg(unix)]
5707        {
5708            use std::os::unix::fs::OpenOptionsExt;
5709            options.mode(0o600);
5710        }
5711        match options.open(&path) {
5712            Ok(mut file) => {
5713                file.write_all(detail.as_bytes())
5714                    .and_then(|_| file.write_all(b"\n"))
5715                    .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5716                return Ok(path);
5717            }
5718            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
5719            Err(e) => {
5720                return Err(spar_err!(
5721                    "could not create a recovery marker in {}: {e}",
5722                    cwd.display()
5723                ))
5724            }
5725        }
5726    }
5727    bail!(
5728        "could not choose a free recovery marker name in {}",
5729        cwd.display()
5730    )
5731}
5732
5733/// Build a Git command that cannot launch automatic repository maintenance.
5734///
5735/// A fetch may otherwise prune missing linked worktree registrations. SPAR
5736/// must only remove registrations it has proven it owns.
5737fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
5738    let mut argv = vec![
5739        "git".to_string(),
5740        "-c".to_string(),
5741        "maintenance.auto=false".to_string(),
5742        "-c".to_string(),
5743        "gc.auto=0".to_string(),
5744    ];
5745    argv.extend(args.iter().map(|arg| (*arg).to_string()));
5746    argv
5747}
5748
5749fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
5750    let mut argv = git_without_maintenance_argv(&[]);
5751    argv.extend([
5752        "-c".to_string(),
5753        "core.fsmonitor=".to_string(),
5754        "-c".to_string(),
5755        "commit.gpgsign=false".to_string(),
5756        "-c".to_string(),
5757        "core.hooksPath=/dev/null".to_string(),
5758    ]);
5759    argv.extend(args.iter().map(|arg| (*arg).to_string()));
5760    argv
5761}
5762
5763/// Snapshot every untracked file, including ignored files, without changing
5764/// path bytes.
5765///
5766/// Without an exclude option, Git lists both ordinary and ignored untracked
5767/// entries. Metadata fingerprints make overwriting an existing path observable
5768/// without hashing a potentially multi-gigabyte build tree on every call.
5769pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
5770    let root = std::fs::canonicalize(cwd)
5771        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5772    let mut files = BTreeMap::new();
5773    let mut ignored = BTreeSet::new();
5774    let mut visited = BTreeSet::new();
5775    collect_untracked_files(
5776        &root,
5777        &root,
5778        Path::new(""),
5779        &mut visited,
5780        &mut files,
5781        &mut ignored,
5782    )?;
5783    Ok(IgnoredState { files, ignored })
5784}
5785
5786fn collect_untracked_files(
5787    root: &Path,
5788    repository: &Path,
5789    prefix: &Path,
5790    visited: &mut BTreeSet<PathBuf>,
5791    files: &mut BTreeMap<PathBuf, UntrackedFile>,
5792    ignored: &mut BTreeSet<PathBuf>,
5793) -> Result<()> {
5794    let canonical = std::fs::canonicalize(repository)
5795        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5796    if !visited.insert(canonical.clone()) {
5797        bail!("submodule recursion revisited {}", canonical.display());
5798    }
5799    // Classify ordinary paths in the listing itself. Listing every path here
5800    // and marking ignored paths in a second query misclassifies build output
5801    // deleted between queries as ordinary untracked work.
5802    let listed = run_git_bytes(
5803        repository,
5804        &["ls-files", "--others", "--exclude-standard", "-z"],
5805    )?;
5806    if !listed.is_empty() && !listed.ends_with(&[0]) {
5807        bail!(
5808            "git returned an unterminated untracked-file list for {}",
5809            repository.display()
5810        );
5811    }
5812
5813    for raw in listed
5814        .split(|byte| *byte == 0)
5815        .filter(|raw| !raw.is_empty())
5816    {
5817        let (relative, nested) = untracked_record(raw, "untracked")?;
5818        let from_root = prefix.join(&relative);
5819        if is_spar_own_path(&from_root) {
5820            continue;
5821        }
5822        let absolute = root.join(&from_root);
5823        let fingerprint = if nested {
5824            nested_repository_fingerprint(&absolute)?
5825        } else {
5826            ignored_file_fingerprint(&absolute)?
5827        };
5828        if files.insert(from_root.clone(), fingerprint).is_some() {
5829            bail!(
5830                "git returned the untracked path more than once: {:?}",
5831                from_root
5832            );
5833        }
5834    }
5835
5836    let ignored_listed = run_git_bytes(
5837        repository,
5838        &[
5839            "ls-files",
5840            "--others",
5841            "--ignored",
5842            "--exclude-standard",
5843            "-z",
5844        ],
5845    )?;
5846    if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
5847        bail!(
5848            "git returned an unterminated ignored-file list for {}",
5849            repository.display()
5850        );
5851    }
5852    // Two `ls-files` runs cannot be made to agree about a tree that is being
5853    // built in. A path named only by the second one was created between them,
5854    // which is a fact about the moment, not a corruption: record it and let the
5855    // comparison decide, rather than ending a run that had nothing wrong with
5856    // it.
5857    for raw in ignored_listed
5858        .split(|byte| *byte == 0)
5859        .filter(|raw| !raw.is_empty())
5860    {
5861        let (relative, nested) = untracked_record(raw, "ignored")?;
5862        let from_root = prefix.join(relative);
5863        if is_spar_own_path(&from_root) {
5864            continue;
5865        }
5866        if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
5867            let absolute = root.join(&from_root);
5868            let fingerprint = if nested {
5869                nested_repository_fingerprint(&absolute)?
5870            } else {
5871                ignored_file_fingerprint(&absolute)?
5872            };
5873            slot.insert(fingerprint);
5874        }
5875        if !ignored.insert(from_root.clone()) {
5876            bail!(
5877                "git returned the ignored path more than once: {:?}",
5878                from_root
5879            );
5880        }
5881    }
5882
5883    for link in gitlinks(repository)? {
5884        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
5885            continue;
5886        };
5887        collect_untracked_files(
5888            root,
5889            &submodule,
5890            &prefix.join(&link.path),
5891            visited,
5892            files,
5893            ignored,
5894        )?;
5895    }
5896    Ok(())
5897}
5898
5899fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
5900    let argv = git_without_automation_argv(args);
5901    proc::run_bytes(
5902        &argv,
5903        &ExecOpts::new()
5904            .cwd(cwd)
5905            .timeout_secs(30)
5906            .stop_descendants(true),
5907    )
5908}
5909
5910fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
5911    let argv = git_without_automation_argv(args);
5912    proc::run(
5913        &argv,
5914        &ExecOpts::new()
5915            .cwd(cwd)
5916            .timeout_secs(30)
5917            .stop_descendants(true),
5918    )
5919}
5920
5921fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
5922    let path = path.to_str().ok_or_else(|| {
5923        spar_err!(
5924            "cannot verify filtered content for a non-UTF-8 path in {}",
5925            cwd.display()
5926        )
5927    })?;
5928    let path_arg = format!("--path={path}");
5929    let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
5930    Ok(Sha256::digest(bytes).into())
5931}
5932
5933fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
5934    let relative = path_from_git_bytes(raw)?;
5935    if relative.is_absolute()
5936        || relative.components().any(|component| {
5937            matches!(
5938                component,
5939                std::path::Component::ParentDir
5940                    | std::path::Component::RootDir
5941                    | std::path::Component::Prefix(_)
5942            )
5943        })
5944    {
5945        bail!("git returned an unsafe {kind} path: {:?}", relative);
5946    }
5947    Ok(relative)
5948}
5949
5950/// Split one `ls-files --others` record into its path and whether Git reported
5951/// a nested repository rather than a single file.
5952///
5953/// Git never lists the contents of a repository inside the working tree, so a
5954/// checkout parked there, such as another of SPAR's own worktrees, arrives as
5955/// one record for the directory itself ending in a separator. Git writes that
5956/// separator on every platform. Trimming it keeps the recorded path equal to
5957/// the same path seen any other way.
5958fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
5959    let nested = raw.last() == Some(&b'/');
5960    let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
5961    if trimmed.is_empty() {
5962        bail!("git returned an empty {kind} path");
5963    }
5964    Ok((safe_git_path(trimmed, kind)?, nested))
5965}
5966
5967fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
5968    let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
5969    if !listed.is_empty() && !listed.ends_with(&[0]) {
5970        bail!(
5971            "git returned an unterminated index listing for {}",
5972            cwd.display()
5973        );
5974    }
5975    let mut entries = Vec::new();
5976    for record in listed
5977        .split(|byte| *byte == 0)
5978        .filter(|record| !record.is_empty())
5979    {
5980        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5981            bail!(
5982                "git returned a malformed index record for {}",
5983                cwd.display()
5984            );
5985        };
5986        let header = &record[..tab];
5987        let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
5988        if fields.len() != 3 {
5989            bail!(
5990                "git returned a malformed index header for {}",
5991                cwd.display()
5992            );
5993        }
5994        if fields[2] != b"0" {
5995            continue;
5996        }
5997        let mode = std::str::from_utf8(fields[0])
5998            .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
5999            .to_string();
6000        let oid = std::str::from_utf8(fields[1])
6001            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6002            .to_string();
6003        entries.push(IndexEntry {
6004            path: safe_git_path(&record[tab + 1..], "index")?,
6005            mode,
6006            oid,
6007        });
6008    }
6009    Ok(entries)
6010}
6011
6012/// Share Git's complete ignore policy between call checkpoints and handoffs.
6013fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
6014    let listed = run_git_bytes(
6015        cwd,
6016        &[
6017            "ls-files",
6018            "--others",
6019            "--exclude-standard",
6020            "-z",
6021            "--",
6022            ".gitattributes",
6023            ":(glob)**/.gitattributes",
6024        ],
6025    )?;
6026    if !listed.is_empty() && !listed.ends_with(&[0]) {
6027        bail!(
6028            "git returned an unterminated attribute-file listing for {}",
6029            cwd.display()
6030        );
6031    }
6032    listed
6033        .split(|byte| *byte == 0)
6034        .filter(|record| !record.is_empty())
6035        .map(|raw| safe_git_path(raw, "attribute"))
6036        .collect()
6037}
6038
6039fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
6040    if !untracked_attribute_paths(cwd)?.is_empty() {
6041        return Ok(true);
6042    }
6043
6044    let index = index_entries(cwd)?
6045        .into_iter()
6046        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6047        .map(|entry| (entry.path, (entry.mode, entry.oid)))
6048        .collect::<BTreeMap<_, _>>();
6049    let head = tree_entries(cwd, "HEAD")?
6050        .into_iter()
6051        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6052        .map(|entry| (entry.path, (entry.mode, entry.oid)))
6053        .collect::<BTreeMap<_, _>>();
6054    if index != head {
6055        return Ok(true);
6056    }
6057
6058    let effective = check_attributes(cwd, index.keys().cloned())?;
6059    let config = CheckoutConfig::read(cwd)?;
6060    for (path, (_mode, oid)) in index {
6061        let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
6062            return Ok(true);
6063        };
6064        let attributes = effective
6065            .get(&path)
6066            .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
6067        if allows_expected_crlf(&config, attributes)? {
6068            if worktree.mode == "120000" {
6069                return Ok(true);
6070            }
6071            let (normalized, every_lf_was_crlf) =
6072                normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
6073            if !every_lf_was_crlf || normalized != oid {
6074                return Ok(true);
6075            }
6076        } else if worktree.raw_oid != oid {
6077            return Ok(true);
6078        }
6079    }
6080    Ok(false)
6081}
6082
6083fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
6084    Ok(index_entries(cwd)?
6085        .into_iter()
6086        .filter(|entry| entry.mode == "160000")
6087        .map(|entry| Gitlink {
6088            path: entry.path,
6089            oid: entry.oid,
6090        })
6091        .collect())
6092}
6093
6094fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
6095    let mut tracked = BTreeMap::new();
6096    for entry in index_entries(cwd)? {
6097        if entry.mode == "160000" {
6098            continue;
6099        }
6100        let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
6101        tracked.insert(
6102            entry.path,
6103            TrackedEntry {
6104                index_mode: entry.mode,
6105                index_oid: entry.oid,
6106                worktree,
6107            },
6108        );
6109    }
6110    Ok(tracked)
6111}
6112
6113fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
6114    let metadata = match std::fs::symlink_metadata(path) {
6115        Ok(metadata) => metadata,
6116        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6117        Err(e) => {
6118            return Err(spar_err!(
6119                "could not inspect tracked file {}: {e}",
6120                path.display()
6121            ))
6122        }
6123    };
6124    let mut fingerprint = Sha256::new();
6125    if metadata.file_type().is_symlink() {
6126        let target = std::fs::read_link(path)
6127            .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
6128        let bytes = os_str_bytes(target.as_os_str())?;
6129        fingerprint.update(b"symlink\0");
6130        fingerprint.update(&bytes);
6131        let content = Sha256::digest(&bytes).into();
6132        return Ok(Some(WorktreeFile {
6133            mode: "120000".to_string(),
6134            #[cfg(unix)]
6135            permissions: 0,
6136            raw_oid: git_blob_oid(oid_len, &bytes)?,
6137            fingerprint: fingerprint.finalize().into(),
6138            content,
6139        }));
6140    }
6141    if !metadata.is_file() {
6142        bail!("tracked path {} is not a file or symlink", path.display());
6143    }
6144
6145    let mut options = OpenOptions::new();
6146    options.read(true);
6147    #[cfg(unix)]
6148    {
6149        use std::os::unix::fs::OpenOptionsExt;
6150        options.custom_flags(libc::O_NOFOLLOW);
6151    }
6152    let mut file = options
6153        .open(path)
6154        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6155    let before = file
6156        .metadata()
6157        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6158    let mode = tracked_file_mode(&before);
6159    #[cfg(unix)]
6160    let permissions = {
6161        use std::os::unix::fs::MetadataExt;
6162        before.mode() & 0o7777
6163    };
6164    fingerprint.update(b"file\0");
6165    fingerprint.update(mode.as_bytes());
6166    #[cfg(unix)]
6167    fingerprint.update(permissions.to_le_bytes());
6168    fingerprint.update(before.len().to_le_bytes());
6169    let mut content = Sha256::new();
6170    let header = format!("blob {}\0", before.len());
6171    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
6172    let mut buf = [0u8; 64 * 1024];
6173    loop {
6174        let read = file
6175            .read(&mut buf)
6176            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6177        if read == 0 {
6178            break;
6179        }
6180        fingerprint.update(&buf[..read]);
6181        content.update(&buf[..read]);
6182        object.update(&buf[..read]);
6183    }
6184    let after = file
6185        .metadata()
6186        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6187    if before.len() != after.len()
6188        || before.modified().ok() != after.modified().ok()
6189        || before.permissions() != after.permissions()
6190    {
6191        bail!(
6192            "tracked file {} changed while it was being inspected",
6193            path.display()
6194        );
6195    }
6196    let current = std::fs::symlink_metadata(path)
6197        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6198    if !same_file(&after, &current) {
6199        bail!(
6200            "tracked file {} was replaced while it was being inspected",
6201            path.display()
6202        );
6203    }
6204    Ok(Some(WorktreeFile {
6205        mode,
6206        #[cfg(unix)]
6207        permissions,
6208        raw_oid: object.finish(),
6209        fingerprint: fingerprint.finalize().into(),
6210        content: content.finalize().into(),
6211    }))
6212}
6213
6214fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
6215    let metadata = std::fs::symlink_metadata(path)
6216        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
6217    let mut digest = Sha256::new();
6218    if metadata.file_type().is_symlink() {
6219        digest.update(b"symlink\0");
6220        let target = std::fs::read_link(path)
6221            .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
6222        digest.update(os_str_bytes(target.as_os_str())?);
6223        return Ok(digest.finalize().into());
6224    }
6225    if !metadata.is_file() {
6226        bail!("attribute path {} is not a file or symlink", path.display());
6227    }
6228    let mut options = OpenOptions::new();
6229    options.read(true);
6230    #[cfg(unix)]
6231    {
6232        use std::os::unix::fs::OpenOptionsExt;
6233        options.custom_flags(libc::O_NOFOLLOW);
6234    }
6235    let mut file = options
6236        .open(path)
6237        .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
6238    let before = file
6239        .metadata()
6240        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
6241    digest.update(b"file\0");
6242    let mut buf = [0u8; 64 * 1024];
6243    loop {
6244        let read = file
6245            .read(&mut buf)
6246            .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
6247        if read == 0 {
6248            break;
6249        }
6250        digest.update(&buf[..read]);
6251    }
6252    let after = file
6253        .metadata()
6254        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
6255    let current = std::fs::symlink_metadata(path)
6256        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
6257    if before.len() != after.len()
6258        || before.modified().ok() != after.modified().ok()
6259        || !same_file(&after, &current)
6260    {
6261        bail!(
6262            "attribute file {} changed while it was being inspected",
6263            path.display()
6264        );
6265    }
6266    Ok(digest.finalize().into())
6267}
6268
6269enum ObjectHasher {
6270    Sha1(Sha1),
6271    Sha256(Sha256),
6272}
6273
6274impl ObjectHasher {
6275    fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
6276        let mut hasher = match oid_len {
6277            40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
6278            64 => Self::Sha256(Sha256::new()),
6279            _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
6280        };
6281        hasher.update(header);
6282        Ok(hasher)
6283    }
6284
6285    fn update(&mut self, bytes: &[u8]) {
6286        match self {
6287            Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
6288            Self::Sha256(hasher) => hasher.update(bytes),
6289        }
6290    }
6291
6292    fn finish(self) -> String {
6293        let bytes = match self {
6294            Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
6295            Self::Sha256(hasher) => hasher.finalize().to_vec(),
6296        };
6297        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
6298    }
6299}
6300
6301fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
6302    let header = format!("blob {}\0", bytes.len());
6303    let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
6304    hasher.update(bytes);
6305    Ok(hasher.finish())
6306}
6307
6308fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
6309    let mut first = open_regular_file(path)?;
6310    let first_before = first
6311        .metadata()
6312        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6313    let mut raw_len = 0u64;
6314    let mut crlf_pairs = 0u64;
6315    let mut previous_was_cr = false;
6316    let mut every_lf_was_crlf = true;
6317    let mut buf = [0u8; 64 * 1024];
6318    loop {
6319        let read = first
6320            .read(&mut buf)
6321            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6322        if read == 0 {
6323            break;
6324        }
6325        raw_len = raw_len
6326            .checked_add(read as u64)
6327            .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
6328        for byte in &buf[..read] {
6329            if *byte == b'\n' {
6330                if previous_was_cr {
6331                    crlf_pairs += 1;
6332                } else {
6333                    every_lf_was_crlf = false;
6334                }
6335            }
6336            previous_was_cr = *byte == b'\r';
6337        }
6338    }
6339    let first_after = first
6340        .metadata()
6341        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6342    let current = std::fs::symlink_metadata(path)
6343        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6344    if raw_len != first_before.len()
6345        || !stable_file_metadata(&first_before, &first_after)
6346        || !stable_file_metadata(&first_after, &current)
6347    {
6348        bail!(
6349            "tracked file {} changed while line endings were inspected",
6350            path.display()
6351        );
6352    }
6353
6354    let normalized_len = raw_len
6355        .checked_sub(crlf_pairs)
6356        .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
6357    let header = format!("blob {normalized_len}\0");
6358    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
6359    let mut second = open_regular_file(path)?;
6360    let second_before = second
6361        .metadata()
6362        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6363    if !stable_file_metadata(&first_after, &second_before) {
6364        bail!(
6365            "tracked file {} changed between line-ending checks",
6366            path.display()
6367        );
6368    }
6369    let mut pending_cr = false;
6370    loop {
6371        let read = second
6372            .read(&mut buf)
6373            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6374        if read == 0 {
6375            break;
6376        }
6377        for byte in &buf[..read] {
6378            if pending_cr {
6379                if *byte == b'\n' {
6380                    object.update(b"\n");
6381                    pending_cr = false;
6382                    continue;
6383                }
6384                object.update(b"\r");
6385                pending_cr = false;
6386            }
6387            if *byte == b'\r' {
6388                pending_cr = true;
6389            } else {
6390                object.update(std::slice::from_ref(byte));
6391            }
6392        }
6393    }
6394    if pending_cr {
6395        object.update(b"\r");
6396    }
6397    let second_after = second
6398        .metadata()
6399        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6400    let current = std::fs::symlink_metadata(path)
6401        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6402    if !stable_file_metadata(&second_before, &second_after)
6403        || !stable_file_metadata(&second_after, &current)
6404    {
6405        bail!(
6406            "tracked file {} changed while line endings were hashed",
6407            path.display()
6408        );
6409    }
6410    Ok((object.finish(), every_lf_was_crlf))
6411}
6412
6413fn open_regular_file(path: &Path) -> Result<std::fs::File> {
6414    let mut options = OpenOptions::new();
6415    options.read(true);
6416    #[cfg(unix)]
6417    {
6418        use std::os::unix::fs::OpenOptionsExt;
6419        options.custom_flags(libc::O_NOFOLLOW);
6420    }
6421    let file = options
6422        .open(path)
6423        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6424    let metadata = file
6425        .metadata()
6426        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6427    if !metadata.is_file() {
6428        bail!("tracked path {} is not a regular file", path.display());
6429    }
6430    Ok(file)
6431}
6432
6433fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
6434    if !same_file(left, right)
6435        || left.len() != right.len()
6436        || left.modified().ok() != right.modified().ok()
6437        || left.permissions() != right.permissions()
6438    {
6439        return false;
6440    }
6441    #[cfg(unix)]
6442    {
6443        use std::os::unix::fs::MetadataExt;
6444        left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
6445    }
6446    #[cfg(not(unix))]
6447    {
6448        left.created().ok() == right.created().ok()
6449    }
6450}
6451
6452/// How many paths one `check-attr` call is asked about.
6453///
6454/// `check-attr` answers with three NUL fields per attribute per path, so the
6455/// output is several times the input. A whole monorepo in one call is megabytes
6456/// in both directions, and the 30 second timeout below is a timeout on the
6457/// batch. Batching keeps each call small enough to be honest about.
6458const ATTRIBUTE_BATCH: usize = 1000;
6459
6460fn check_attributes(
6461    cwd: &Path,
6462    paths: impl IntoIterator<Item = PathBuf>,
6463) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
6464    let paths = paths.into_iter().collect::<BTreeSet<_>>();
6465    if paths.is_empty() {
6466        return Ok(BTreeMap::new());
6467    }
6468    let ordered = paths.into_iter().collect::<Vec<_>>();
6469    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
6470    for batch in ordered.chunks(ATTRIBUTE_BATCH) {
6471        values.extend(check_attribute_batch(cwd, batch)?);
6472    }
6473    Ok(values)
6474}
6475
6476fn check_attribute_batch(
6477    cwd: &Path,
6478    batch: &[PathBuf],
6479) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
6480    const NAMES: [&str; 6] = [
6481        "filter",
6482        "working-tree-encoding",
6483        "ident",
6484        "text",
6485        "eol",
6486        "crlf",
6487    ];
6488    let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
6489    let mut input = String::new();
6490    for path in &paths {
6491        let path = path.to_str().ok_or_else(|| {
6492            spar_err!(
6493                "cannot inspect attributes for a non-UTF-8 path in {}",
6494                cwd.display()
6495            )
6496        })?;
6497        input.push_str(path);
6498        input.push('\0');
6499    }
6500    let argv = git_without_automation_argv(&[
6501        "check-attr",
6502        "-z",
6503        "--cached",
6504        "--stdin",
6505        "filter",
6506        "working-tree-encoding",
6507        "ident",
6508        "text",
6509        "eol",
6510        "crlf",
6511    ]);
6512    let output = proc::run_bytes(
6513        &argv,
6514        &ExecOpts::new()
6515            .cwd(cwd)
6516            .timeout_secs(30)
6517            .stdin(input)
6518            .stop_descendants(true),
6519    )?;
6520    if !output.is_empty() && !output.ends_with(&[0]) {
6521        bail!(
6522            "git returned an unterminated attribute result for {}",
6523            cwd.display()
6524        );
6525    }
6526    let fields = output
6527        .split(|byte| *byte == 0)
6528        .filter(|field| !field.is_empty())
6529        .collect::<Vec<_>>();
6530    if fields.len() != paths.len() * NAMES.len() * 3 {
6531        bail!(
6532            "git returned an unexpected attribute result for {}",
6533            cwd.display()
6534        );
6535    }
6536    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
6537    for record in fields.chunks_exact(3) {
6538        let path = safe_git_path(record[0], "attribute")?;
6539        if !paths.contains(&path) {
6540            bail!(
6541                "git returned attributes for the wrong path in {}",
6542                cwd.display()
6543            );
6544        }
6545        let name = std::str::from_utf8(record[1])
6546            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
6547        let value = std::str::from_utf8(record[2])
6548            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
6549        values
6550            .entry(path)
6551            .or_default()
6552            .insert(name.to_string(), value.to_string());
6553    }
6554    if paths.iter().any(|path| {
6555        values
6556            .get(path)
6557            .is_none_or(|attributes| attributes.len() != NAMES.len())
6558    }) {
6559        bail!(
6560            "git omitted an attribute result for a tracked path in {}",
6561            cwd.display()
6562        );
6563    }
6564    Ok(values)
6565}
6566
6567fn attribute_is_active(value: Option<&String>) -> bool {
6568    !matches!(
6569        value.map(String::as_str),
6570        None | Some("unspecified") | Some("unset")
6571    )
6572}
6573
6574fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
6575    attribute_is_active(values.get("filter"))
6576        || attribute_is_active(values.get("working-tree-encoding"))
6577}
6578
6579/// The checkout settings a per-file line-ending decision depends on.
6580///
6581/// These are constant for a worktree, but the checks that read them run once
6582/// per tracked file, and each read was its own `git config` process. A
6583/// thousand-file repository with no `.gitattributes` takes every one of those
6584/// branches, which is half a minute of process spawning per scan, and the sweep
6585/// before a run scans every finished worktree more than once. Read them here,
6586/// once, and hand them down.
6587struct CheckoutConfig {
6588    autocrlf: Option<String>,
6589    eol: Option<String>,
6590    symlinks: Option<bool>,
6591}
6592
6593impl CheckoutConfig {
6594    fn read(cwd: &Path) -> Result<Self> {
6595        Ok(Self {
6596            autocrlf: git_config_value(cwd, "core.autocrlf")?,
6597            eol: git_config_value(cwd, "core.eol")?,
6598            symlinks: git_config_bool(cwd, "core.symlinks")?,
6599        })
6600    }
6601
6602    fn autocrlf_is_true(&self) -> bool {
6603        self.autocrlf.as_ref().is_some_and(|value| {
6604            matches!(
6605                value.to_ascii_lowercase().as_str(),
6606                "true" | "yes" | "on" | "1"
6607            )
6608        })
6609    }
6610
6611    fn eol_is(&self, wanted: &str) -> bool {
6612        self.eol
6613            .as_ref()
6614            .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
6615    }
6616}
6617
6618fn path_has_ambiguous_transform(
6619    config: &CheckoutConfig,
6620    values: &BTreeMap<String, String>,
6621) -> Result<bool> {
6622    if path_has_external_transform(values)
6623        || attribute_is_active(values.get("ident"))
6624        || attribute_is_active(values.get("crlf"))
6625    {
6626        return Ok(true);
6627    }
6628    let text = values.get("text").map(String::as_str);
6629    let eol = values.get("eol").map(String::as_str);
6630    if text == Some("auto") {
6631        return Ok(true);
6632    }
6633    if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
6634        || !matches!(
6635            eol,
6636            Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
6637        )
6638    {
6639        return Ok(true);
6640    }
6641    if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
6642        return Ok(config.autocrlf_is_true());
6643    }
6644    Ok(false)
6645}
6646
6647fn allows_expected_crlf(
6648    config: &CheckoutConfig,
6649    values: &BTreeMap<String, String>,
6650) -> Result<bool> {
6651    if path_has_external_transform(values)
6652        || attribute_is_active(values.get("ident"))
6653        || attribute_is_active(values.get("crlf"))
6654    {
6655        return Ok(false);
6656    }
6657    let text = values.get("text").map(String::as_str);
6658    let eol = values.get("eol").map(String::as_str);
6659    if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
6660        return Ok(false);
6661    }
6662    if eol == Some("crlf") {
6663        return Ok(true);
6664    }
6665    if text != Some("set") {
6666        return Ok(false);
6667    }
6668    if let Some(autocrlf) = config.autocrlf.as_deref() {
6669        match autocrlf.to_ascii_lowercase().as_str() {
6670            "true" | "yes" | "on" | "1" => return Ok(true),
6671            "input" => return Ok(false),
6672            _ => {}
6673        }
6674    }
6675    if config.eol_is("crlf") {
6676        return Ok(true);
6677    }
6678    #[cfg(windows)]
6679    if config.eol.is_none() || config.eol_is("native") {
6680        return Ok(true);
6681    }
6682    Ok(false)
6683}
6684
6685fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
6686    let argv = git_without_automation_argv(&["config", "--get", key]);
6687    let output = proc::exec(
6688        &argv,
6689        &ExecOpts::new()
6690            .cwd(cwd)
6691            .timeout_secs(30)
6692            .check(false)
6693            .stop_descendants(true),
6694    )?;
6695    match output.code {
6696        0 => Ok(Some(output.stdout.trim().to_string())),
6697        1 => Ok(None),
6698        _ => bail!(
6699            "could not read Git configuration in {}: {}",
6700            cwd.display(),
6701            output.stderr.trim()
6702        ),
6703    }
6704}
6705
6706fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
6707    let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
6708    let output = proc::exec(
6709        &argv,
6710        &ExecOpts::new()
6711            .cwd(cwd)
6712            .timeout_secs(30)
6713            .check(false)
6714            .stop_descendants(true),
6715    )?;
6716    match output.code {
6717        0 if output.stdout.trim() == "true" => Ok(Some(true)),
6718        0 if output.stdout.trim() == "false" => Ok(Some(false)),
6719        0 => bail!(
6720            "git returned an invalid boolean for {key} in {}",
6721            cwd.display()
6722        ),
6723        1 => Ok(None),
6724        _ => bail!(
6725            "could not read Git configuration in {}: {}",
6726            cwd.display(),
6727            output.stderr.trim()
6728        ),
6729    }
6730}
6731
6732#[cfg(unix)]
6733fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
6734    use std::os::unix::fs::PermissionsExt;
6735    if metadata.permissions().mode() & 0o111 == 0 {
6736        "100644".to_string()
6737    } else {
6738        "100755".to_string()
6739    }
6740}
6741
6742#[cfg(not(unix))]
6743fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
6744    "100644".to_string()
6745}
6746
6747fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
6748    let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
6749    if !listed.is_empty() && !listed.ends_with(&[0]) {
6750        bail!(
6751            "git returned an unterminated tree listing for {}",
6752            cwd.display()
6753        );
6754    }
6755    let mut entries = Vec::new();
6756    for record in listed
6757        .split(|byte| *byte == 0)
6758        .filter(|record| !record.is_empty())
6759    {
6760        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6761            bail!("git returned a malformed tree record for {}", cwd.display());
6762        };
6763        let fields = record[..tab]
6764            .split(|byte| *byte == b' ')
6765            .collect::<Vec<_>>();
6766        if fields.len() != 3 {
6767            bail!("git returned a malformed tree header for {}", cwd.display());
6768        }
6769        let mode = std::str::from_utf8(fields[0])
6770            .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
6771            .to_string();
6772        let oid = std::str::from_utf8(fields[2])
6773            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6774            .to_string();
6775        entries.push(IndexEntry {
6776            path: safe_git_path(&record[tab + 1..], "tree")?,
6777            mode,
6778            oid,
6779        });
6780    }
6781    Ok(entries)
6782}
6783
6784fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
6785    Ok(tree_entries(cwd, "HEAD")?
6786        .into_iter()
6787        .filter(|entry| entry.mode == "160000")
6788        .map(|entry| (entry.path, entry.oid))
6789        .collect())
6790}
6791
6792fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
6793    let head = head_gitlinks(cwd)?;
6794    let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
6795        .into_iter()
6796        .map(|link| (link.path, link.oid))
6797        .collect();
6798    let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
6799    paths.extend(index.keys().cloned());
6800    Ok(paths
6801        .into_iter()
6802        .filter(|path| head.get(path) != index.get(path))
6803        .collect())
6804}
6805
6806fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
6807    let path = parent.join(relative);
6808    let metadata = match std::fs::symlink_metadata(&path) {
6809        Ok(metadata) => metadata,
6810        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6811        Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6812    };
6813    if !metadata.is_dir() {
6814        bail!("the gitlink at {} is not a directory", path.display());
6815    }
6816    let canonical = std::fs::canonicalize(&path)
6817        .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
6818    if canonical != path {
6819        bail!(
6820            "the gitlink at {} resolves through a symlink",
6821            path.display()
6822        );
6823    }
6824    if !path.join(".git").exists() {
6825        let empty = std::fs::read_dir(&path)
6826            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
6827            .next()
6828            .is_none();
6829        if empty {
6830            return Ok(None);
6831        }
6832        bail!(
6833            "the uninitialized gitlink at {} contains local files",
6834            path.display()
6835        );
6836    }
6837    let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
6838    if inside.trim() != "true" {
6839        bail!("the gitlink at {} is not a worktree", path.display());
6840    }
6841    let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
6842    let top = std::fs::canonicalize(top.trim()).map_err(|e| {
6843        spar_err!(
6844            "could not resolve the gitlink top level at {}: {e}",
6845            path.display()
6846        )
6847    })?;
6848    if top != canonical {
6849        bail!(
6850            "the gitlink at {} belongs to a different worktree",
6851            path.display()
6852        );
6853    }
6854    Ok(Some(canonical))
6855}
6856
6857/// A Git entry the outer repository cannot account for.
6858///
6859/// A checkout Git itself reports as a nested repository is expected. Its
6860/// directory is recorded by identity in the untracked snapshot and keeps the
6861/// worktree from being released, so a reviewer that clones a reproduction into
6862/// ignored build output neither loses that clone nor fails the run over it.
6863/// The scan does not descend into one, because what is under it belongs to
6864/// that repository.
6865///
6866/// A Git entry Git does not report is the one worth stopping for. Running
6867/// `git init` inside a directory that already holds tracked files leaves
6868/// nothing in any listing while making every file written under it invisible
6869/// to the outer worktree, so SPAR can no longer prove what is there.
6870fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
6871    let root = std::fs::canonicalize(cwd)
6872        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6873    let mut allowed = BTreeSet::from([root.join(".git")]);
6874    let mut checkouts = BTreeSet::new();
6875    let mut repositories = vec![root.clone()];
6876    let mut visited = BTreeSet::new();
6877    while let Some(repository) = repositories.pop() {
6878        let canonical = std::fs::canonicalize(&repository)
6879            .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6880        if !visited.insert(canonical.clone()) {
6881            bail!("submodule recursion revisited {}", canonical.display());
6882        }
6883        for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
6884            let (path, nested) = untracked_record(&raw, "untracked")?;
6885            if nested {
6886                checkouts.insert(canonical.join(path));
6887            }
6888        }
6889        for link in gitlinks(&canonical)? {
6890            let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
6891                continue;
6892            };
6893            allowed.insert(submodule.join(".git"));
6894            repositories.push(submodule);
6895        }
6896    }
6897
6898    // A build deletes its own directories while this walks them. A directory
6899    // that is gone holds no Git entry, so skipping it answers the question
6900    // being asked; erroring instead ended runs whose worktrees were fine.
6901    let scan_root = root.clone();
6902    let mut directories = vec![root];
6903    while let Some(directory) = directories.pop() {
6904        let entries = match std::fs::read_dir(&directory) {
6905            Ok(entries) => entries,
6906            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6907            Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6908        };
6909        for entry in entries {
6910            let entry = match entry {
6911                Ok(entry) => entry,
6912                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6913                Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6914            };
6915            let path = entry.path();
6916            if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
6917                continue;
6918            }
6919            if entry.file_name() == OsStr::new(".git") {
6920                if !allowed.contains(&path) {
6921                    return Ok(Some(path));
6922                }
6923                continue;
6924            }
6925            let kind = match entry.file_type() {
6926                Ok(kind) => kind,
6927                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6928                Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6929            };
6930            if kind.is_dir() && !checkouts.contains(&path) {
6931                directories.push(path);
6932            }
6933        }
6934    }
6935    Ok(None)
6936}
6937
6938pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
6939    if let Some(path) = unexpected_nested_git_entry(cwd)? {
6940        bail!(
6941            "the worktree contains a Git entry at {} that Git does not report as a nested \
6942             checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
6943             prove they are unchanged.",
6944            path.display()
6945        );
6946    }
6947    let root = std::fs::canonicalize(cwd)
6948        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6949    let mut repositories = BTreeMap::new();
6950    let mut visited = BTreeSet::new();
6951    collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
6952    Ok(GitState { repositories })
6953}
6954
6955fn collect_git_state(
6956    repository: &Path,
6957    prefix: &Path,
6958    visited: &mut BTreeSet<PathBuf>,
6959    repositories: &mut BTreeMap<PathBuf, RepositoryState>,
6960) -> Result<()> {
6961    let canonical = std::fs::canonicalize(repository)
6962        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6963    if !visited.insert(canonical.clone()) {
6964        bail!("submodule recursion revisited {}", canonical.display());
6965    }
6966    let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6967    let head = head.trim().to_string();
6968    if head.is_empty() {
6969        bail!("git returned an empty head for {}", repository.display());
6970    }
6971    let unsafe_index_flags = unsafe_index_flags(repository)?;
6972    let tracked = tracked_entries(repository)?;
6973    let gitlinks = gitlinks(repository)?;
6974    if repositories
6975        .insert(
6976            prefix.to_path_buf(),
6977            RepositoryState {
6978                head,
6979                unsafe_index_flags,
6980                tracked,
6981                gitlinks: gitlinks
6982                    .iter()
6983                    .map(|link| (link.path.clone(), link.oid.clone()))
6984                    .collect(),
6985            },
6986        )
6987        .is_some()
6988    {
6989        bail!("Git state contains duplicate repository path {:?}", prefix);
6990    }
6991
6992    for link in gitlinks {
6993        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6994            continue;
6995        };
6996        collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
6997    }
6998    Ok(())
6999}
7000
7001fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
7002    let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
7003    if !listed.is_empty() && !listed.ends_with(&[0]) {
7004        bail!(
7005            "git returned an unterminated index-flag listing for {}",
7006            cwd.display()
7007        );
7008    }
7009    let mut unsafe_records = Vec::new();
7010    for record in listed
7011        .split(|byte| *byte == 0)
7012        .filter(|record| !record.is_empty())
7013    {
7014        if record.len() < 3 || record[1] != b' ' {
7015            bail!(
7016                "git returned a malformed index-flag record for {}",
7017                cwd.display()
7018            );
7019        }
7020        if record[0] != b'H' {
7021            unsafe_records.extend_from_slice(record);
7022            unsafe_records.push(0);
7023        }
7024    }
7025    Ok(unsafe_records)
7026}
7027
7028pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
7029    safe_git_state(cwd).map(|_| ())
7030}
7031
7032pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
7033    let state = git_state(cwd)?;
7034    if let Some((path, _repository)) = state
7035        .repositories
7036        .iter()
7037        .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
7038    {
7039        let label = if path.as_os_str().is_empty() {
7040            cwd.to_path_buf()
7041        } else {
7042            cwd.join(path)
7043        };
7044        bail!(
7045            "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
7046             SPAR cannot prove the working files are unchanged, so it was kept.",
7047            label.display()
7048        );
7049    }
7050    Ok(state)
7051}
7052
7053fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
7054    repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
7055}
7056
7057fn repository_has_recoverable_work_with(
7058    cwd: &Path,
7059    include_ignored: bool,
7060    disposable: Disposable,
7061) -> Result<bool> {
7062    if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
7063        return Ok(true);
7064    }
7065    let mut visited = BTreeSet::new();
7066    repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
7067}
7068
7069fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
7070    let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
7071    let git_dir = PathBuf::from(git_dir.trim());
7072    let git_dir = if git_dir.is_absolute() {
7073        git_dir
7074    } else {
7075        cwd.join(git_dir)
7076    };
7077    let git_dir = std::fs::canonicalize(&git_dir)
7078        .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
7079    match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
7080        Ok(_) => return Ok(true),
7081        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7082        Err(error) => {
7083            return Err(spar_err!(
7084                "could not inspect per-worktree configuration in {}: {error}",
7085                git_dir.display()
7086            ))
7087        }
7088    }
7089
7090    let orig_head = git_dir.join("ORIG_HEAD");
7091    match std::fs::symlink_metadata(&orig_head) {
7092        Ok(metadata) if metadata.is_file() => {
7093            let oid = std::fs::read_to_string(&orig_head)
7094                .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
7095            let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
7096                return Ok(true);
7097            };
7098            if !commit_has_shared_ref(cwd, &commit)? {
7099                return Ok(true);
7100            }
7101        }
7102        Ok(_) => return Ok(true),
7103        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7104        Err(error) => {
7105            return Err(spar_err!(
7106                "could not inspect {}: {error}",
7107                orig_head.display()
7108            ))
7109        }
7110    }
7111
7112    let edit_message = git_dir.join("COMMIT_EDITMSG");
7113    match std::fs::symlink_metadata(&edit_message) {
7114        Ok(metadata) if metadata.is_file() => {
7115            let draft = std::fs::read(&edit_message)
7116                .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
7117            if draft != head_commit_message(cwd)? {
7118                return Ok(true);
7119            }
7120        }
7121        Ok(_) => return Ok(true),
7122        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7123        Err(error) => {
7124            return Err(spar_err!(
7125                "could not inspect {}: {error}",
7126                edit_message.display()
7127            ))
7128        }
7129    }
7130
7131    if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
7132        return Ok(true);
7133    }
7134
7135    let local_refs = run_git_bytes(
7136        cwd,
7137        &[
7138            "for-each-ref",
7139            "--format=%(refname)",
7140            "refs/worktree",
7141            "refs/bisect",
7142            "refs/rewritten",
7143        ],
7144    )?;
7145    if !local_refs.is_empty() {
7146        return Ok(true);
7147    }
7148
7149    // `FETCH_HEAD` only names what a `git fetch` in this worktree brought
7150    // down. The objects live in the shared store and the next fetch rewrites
7151    // it, so an agent that fetched once must not keep the checkout forever.
7152    for entry in std::fs::read_dir(&git_dir)
7153        .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
7154    {
7155        let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
7156        let known = matches!(
7157            entry.file_name().to_str(),
7158            Some(
7159                "HEAD"
7160                    | "ORIG_HEAD"
7161                    | "COMMIT_EDITMSG"
7162                    | "FETCH_HEAD"
7163                    | "commondir"
7164                    | "gitdir"
7165                    | "index"
7166                    | "logs"
7167                    | "refs"
7168            )
7169        );
7170        if !known {
7171            return Ok(true);
7172        }
7173    }
7174
7175    let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7176    if !commit_has_shared_ref(cwd, head.trim())? {
7177        return Ok(true);
7178    }
7179    Ok(false)
7180}
7181
7182fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
7183    let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
7184    let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
7185        bail!(
7186            "git returned a commit without a message separator in {}",
7187            cwd.display()
7188        );
7189    };
7190    Ok(commit[split + 2..].to_vec())
7191}
7192
7193fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
7194    let metadata = match std::fs::symlink_metadata(logs) {
7195        Ok(metadata) => metadata,
7196        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
7197        Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
7198    };
7199    if !metadata.is_dir() {
7200        return Ok(true);
7201    }
7202    let mut files = Vec::new();
7203    let mut directories = vec![logs.to_path_buf()];
7204    while let Some(directory) = directories.pop() {
7205        for entry in std::fs::read_dir(&directory)
7206            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
7207        {
7208            let entry =
7209                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
7210            let path = entry.path();
7211            let kind = entry
7212                .file_type()
7213                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
7214            if kind.is_dir() {
7215                directories.push(path);
7216            } else if kind.is_file() {
7217                files.push(path);
7218            } else {
7219                return Ok(true);
7220            }
7221        }
7222    }
7223
7224    let mut commits = BTreeSet::new();
7225    for path in files {
7226        if !collect_reflog_commits(cwd, &path, &mut commits)? {
7227            return Ok(true);
7228        }
7229    }
7230    for commit in commits {
7231        if !commit_has_shared_ref(cwd, &commit)? {
7232            return Ok(true);
7233        }
7234    }
7235    Ok(false)
7236}
7237
7238/// Every commit named by one ref's common reflog must survive deletion of that
7239/// ref. Ancestors of a durable current tip survive with the tip; divergent
7240/// entries need another shared ref of their own.
7241fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
7242    let common = common_git_dir(cwd)?;
7243    let reflog = common.join("logs").join(refname);
7244    let metadata = match std::fs::symlink_metadata(&reflog) {
7245        Ok(metadata) => metadata,
7246        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
7247        Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
7248    };
7249    if !metadata.is_file() {
7250        return Ok(false);
7251    }
7252    let mut commits = BTreeSet::new();
7253    if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
7254        return Ok(false);
7255    }
7256    for commit in commits {
7257        if is_ancestor(cwd, &commit, durable_tip)?
7258            || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
7259        {
7260            continue;
7261        }
7262        return Ok(false);
7263    }
7264    Ok(true)
7265}
7266
7267fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
7268    let file = std::fs::File::open(path)
7269        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
7270    for line in std::io::BufReader::new(file).lines() {
7271        let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
7272        let mut fields = line.splitn(3, ' ');
7273        let Some(old) = fields.next() else {
7274            return Ok(false);
7275        };
7276        let Some(new) = fields.next() else {
7277            return Ok(false);
7278        };
7279        if fields.next().is_none() {
7280            return Ok(false);
7281        }
7282        for oid in [old, new] {
7283            if oid.bytes().all(|byte| byte == b'0') {
7284                continue;
7285            }
7286            let Some(commit) = resolve_optional_commit(cwd, oid)? else {
7287                return Ok(false);
7288            };
7289            commits.insert(commit);
7290        }
7291    }
7292    Ok(true)
7293}
7294
7295fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
7296    let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
7297    let path = PathBuf::from(raw.trim());
7298    let path = if path.is_absolute() {
7299        path
7300    } else {
7301        cwd.join(path)
7302    };
7303    std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
7304}
7305
7306fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
7307    let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
7308    let output = proc::exec(
7309        &argv,
7310        &ExecOpts::new()
7311            .cwd(cwd)
7312            .timeout_secs(30)
7313            .check(false)
7314            .stop_descendants(true),
7315    )?;
7316    match output.code {
7317        0 => Ok(true),
7318        1 => Ok(false),
7319        _ => bail!("{}", proc::failure_message(&argv, &output)),
7320    }
7321}
7322
7323fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
7324    let commit = format!("{oid}^{{commit}}");
7325    let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
7326    let output = proc::exec(
7327        &argv,
7328        &ExecOpts::new()
7329            .cwd(cwd)
7330            .timeout_secs(30)
7331            .check(false)
7332            .stop_descendants(true),
7333    )?;
7334    if output.code != 0 {
7335        return Ok(None);
7336    }
7337    let oid = output.stdout.trim();
7338    if oid.is_empty() {
7339        return Ok(None);
7340    }
7341    Ok(Some(oid.to_string()))
7342}
7343
7344fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
7345    commit_has_shared_ref_except(cwd, oid, None)
7346}
7347
7348fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
7349    let contains = format!("--contains={oid}");
7350    let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
7351    Ok(shared.split(|byte| *byte == b'\n').any(|record| {
7352        !record.is_empty()
7353            && !record.starts_with(b"refs/worktree/")
7354            && !record.starts_with(b"refs/bisect/")
7355            && !record.starts_with(b"refs/rewritten/")
7356            && exclude.is_none_or(|excluded| record != excluded.as_bytes())
7357    }))
7358}
7359
7360/// Whether removing the worktree would take away an untracked file somebody
7361/// might want back.
7362///
7363/// Ordinary untracked files always count. So does an ignored file outside the
7364/// known build and cache directories, because an ignored path is only a path
7365/// Git was told not to track, which is where a local `.env` lives as readily as
7366/// compiler output.
7367///
7368/// Recognized build and cache output does not. A managed commit already leaves
7369/// it out rather than treating it as work, and the command that wrote it writes
7370/// it again. Counting it kept every worktree whose tests or build had run,
7371/// which is nearly all of them, so a merged pull request still left its
7372/// checkout behind. A repository nested in that output is somebody else's
7373/// history and counts whatever it sits under.
7374fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
7375    let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
7376    for raw in &ordinary {
7377        let (path, _) = untracked_record(raw, "untracked")?;
7378        if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
7379            continue;
7380        }
7381        return Ok(true);
7382    }
7383    let listed = untracked_listing(
7384        cwd,
7385        &[
7386            "ls-files",
7387            "--others",
7388            "--ignored",
7389            "--exclude-standard",
7390            "-z",
7391        ],
7392    )?;
7393    for raw in listed {
7394        let (path, nested) = untracked_record(&raw, "ignored")?;
7395        // A repository cloned into the output keeps the checkout whatever the
7396        // mode: its objects exist nowhere else, which is the one thing a
7397        // rebuild from origin cannot replace.
7398        if nested {
7399            return Ok(true);
7400        }
7401        if disposable == Disposable::BuildOutput
7402            && !is_generated_artifact(&path)
7403            && !is_desktop_metadata(&path)
7404        {
7405            return Ok(true);
7406        }
7407    }
7408    Ok(false)
7409}
7410
7411fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
7412    let listed = run_git_bytes(cwd, args)?;
7413    if !listed.is_empty() && !listed.ends_with(&[0]) {
7414        bail!(
7415            "git returned an unterminated untracked-file list for {}",
7416            cwd.display()
7417        );
7418    }
7419    Ok(listed
7420        .split(|byte| *byte == 0)
7421        .filter(|raw| !raw.is_empty())
7422        .map(|raw| raw.to_vec())
7423        .collect())
7424}
7425
7426fn repository_has_recoverable_work_inner(
7427    cwd: &Path,
7428    include_ignored: bool,
7429    disposable: Disposable,
7430    visited: &mut BTreeSet<PathBuf>,
7431) -> Result<bool> {
7432    let canonical = std::fs::canonicalize(cwd)
7433        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7434    if !visited.insert(canonical.clone()) {
7435        bail!("submodule recursion revisited {}", canonical.display());
7436    }
7437    if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
7438        return Ok(true);
7439    }
7440    if !unsafe_index_flags(cwd)?.is_empty() {
7441        return Ok(true);
7442    }
7443    if attributes_may_be_modified(cwd)? {
7444        return Ok(true);
7445    }
7446    if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
7447        return Ok(true);
7448    }
7449    if include_ignored {
7450        let index = index_entries(cwd)?
7451            .into_iter()
7452            .map(|entry| (entry.path, (entry.mode, entry.oid)))
7453            .collect::<BTreeMap<_, _>>();
7454        let head = tree_entries(cwd, "HEAD")?
7455            .into_iter()
7456            .map(|entry| (entry.path, (entry.mode, entry.oid)))
7457            .collect::<BTreeMap<_, _>>();
7458        if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
7459            return Ok(true);
7460        }
7461        let tracked = tracked_entries(cwd)?;
7462        let effective = check_attributes(cwd, tracked.keys().cloned())?;
7463        let config = CheckoutConfig::read(cwd)?;
7464        for (path, entry) in tracked {
7465            let Some(worktree) = entry.worktree else {
7466                return Ok(true);
7467            };
7468            let attributes = effective.get(&path).ok_or_else(|| {
7469                spar_err!("git omitted attributes for {}", cwd.join(&path).display())
7470            })?;
7471            if path_has_ambiguous_transform(&config, attributes)? {
7472                return Ok(true);
7473            }
7474            let symlink_file = entry.index_mode == "120000"
7475                && worktree.mode == "100644"
7476                && worktree.raw_oid == entry.index_oid
7477                && config.symlinks == Some(false);
7478            if worktree.mode != entry.index_mode && !symlink_file {
7479                return Ok(true);
7480            }
7481            if entry.index_mode == "120000" {
7482                if worktree.raw_oid != entry.index_oid {
7483                    return Ok(true);
7484                }
7485                continue;
7486            }
7487            #[cfg(unix)]
7488            {
7489                let expected = if entry.index_mode == "100755" {
7490                    0o755
7491                } else {
7492                    0o644
7493                };
7494                if worktree.permissions != expected {
7495                    return Ok(true);
7496                }
7497            }
7498            if allows_expected_crlf(&config, attributes)? {
7499                let (normalized, every_lf_was_crlf) =
7500                    normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
7501                if !every_lf_was_crlf || normalized != entry.index_oid {
7502                    return Ok(true);
7503                }
7504            } else if worktree.raw_oid != entry.index_oid {
7505                return Ok(true);
7506            }
7507        }
7508    } else {
7509        let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
7510        if !run_git_bytes(cwd, &args)?.is_empty() {
7511            return Ok(true);
7512        }
7513    }
7514    for link in gitlinks(cwd)? {
7515        let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
7516            continue;
7517        };
7518        // Git stores a linked worktree's initialized submodule objects under
7519        // that worktree's administrative directory. Ordinary removal cannot
7520        // prove a local submodule commit exists anywhere else, even when both
7521        // working trees look clean.
7522        if include_ignored {
7523            return Ok(true);
7524        }
7525        let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7526        if head.trim() != link.oid {
7527            return Ok(true);
7528        }
7529        if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
7530        {
7531            return Ok(true);
7532        }
7533    }
7534    Ok(false)
7535}
7536
7537pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
7538    repository_has_recoverable_work(cwd, false)
7539}
7540
7541fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
7542    let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
7543    Ok(!run_git_bytes(cwd, &args)?.is_empty())
7544}
7545
7546#[cfg(unix)]
7547fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
7548    use std::os::unix::ffi::OsStringExt;
7549    Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
7550}
7551
7552#[cfg(not(unix))]
7553fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
7554    String::from_utf8(raw.to_vec())
7555        .map(PathBuf::from)
7556        .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
7557}
7558
7559/// Fingerprint the directory of a nested repository without reading inside it.
7560///
7561/// The files under it are that repository's, not this one's. They are recorded
7562/// against its own baseline whenever SPAR works there, and a run of its own may
7563/// legitimately add or remove entries while this call is in flight, so the
7564/// volatile directory fields stay out of the fingerprint. Identity and type
7565/// remain, which is what makes deleting the checkout, or replacing it with a
7566/// file, observable from the outer worktree.
7567/// What a path Git listed looks like once it is no longer there.
7568///
7569/// A reviewer building the project deletes and rewrites its output constantly,
7570/// so a path can go between the listing that named it and the stat that
7571/// measures it. Treating that as an unreadable worktree ended runs that had
7572/// nothing wrong with them. The record stays in the snapshot rather than being
7573/// dropped, so a path that vanishes cannot pass for one that was never there.
7574fn vanished_untracked_file() -> UntrackedFile {
7575    UntrackedFile {
7576        kind: 0,
7577        len: 0,
7578        modified: None,
7579        created: None,
7580        readonly: false,
7581        symlink_target: None,
7582        #[cfg(unix)]
7583        device: 0,
7584        #[cfg(unix)]
7585        inode: 0,
7586        #[cfg(unix)]
7587        mode: 0,
7588        #[cfg(unix)]
7589        change_seconds: 0,
7590        #[cfg(unix)]
7591        change_nanoseconds: 0,
7592    }
7593}
7594
7595fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
7596    let metadata = match std::fs::symlink_metadata(path) {
7597        Ok(metadata) => metadata,
7598        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7599        Err(e) => {
7600            return Err(spar_err!(
7601                "could not inspect the nested repository at {}: {e}",
7602                path.display()
7603            ))
7604        }
7605    };
7606    if !metadata.is_dir() {
7607        bail!(
7608            "git reported {} as a nested repository, but it is not a directory",
7609            path.display()
7610        );
7611    }
7612    if std::fs::symlink_metadata(path.join(".git")).is_err() {
7613        bail!(
7614            "git reported {} as a nested repository, but it has no Git entry",
7615            path.display()
7616        );
7617    }
7618    #[cfg(unix)]
7619    {
7620        use std::os::unix::fs::MetadataExt;
7621        Ok(UntrackedFile {
7622            kind: 3,
7623            len: 0,
7624            modified: None,
7625            created: metadata.created().ok(),
7626            readonly: metadata.permissions().readonly(),
7627            symlink_target: None,
7628            device: metadata.dev(),
7629            inode: metadata.ino(),
7630            mode: metadata.mode(),
7631            change_seconds: 0,
7632            change_nanoseconds: 0,
7633        })
7634    }
7635    #[cfg(not(unix))]
7636    {
7637        Ok(UntrackedFile {
7638            kind: 3,
7639            len: 0,
7640            modified: None,
7641            created: metadata.created().ok(),
7642            readonly: metadata.permissions().readonly(),
7643            symlink_target: None,
7644        })
7645    }
7646}
7647
7648fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
7649    let metadata = match std::fs::symlink_metadata(path) {
7650        Ok(metadata) => metadata,
7651        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7652        Err(e) => {
7653            return Err(spar_err!(
7654                "could not inspect untracked file {}: {e}",
7655                path.display()
7656            ))
7657        }
7658    };
7659    let kind = if metadata.file_type().is_symlink() {
7660        2
7661    } else if metadata.is_file() {
7662        1
7663    } else {
7664        bail!(
7665            "untracked path {} is not a regular file or symlink",
7666            path.display()
7667        );
7668    };
7669    let symlink_target = if kind == 2 {
7670        match std::fs::read_link(path) {
7671            Ok(target) => Some(os_str_bytes(target.as_os_str())?),
7672            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7673                return Ok(vanished_untracked_file())
7674            }
7675            Err(e) => {
7676                return Err(spar_err!(
7677                    "could not read untracked symlink {}: {e}",
7678                    path.display()
7679                ))
7680            }
7681        }
7682    } else {
7683        None
7684    };
7685    #[cfg(unix)]
7686    {
7687        use std::os::unix::fs::MetadataExt;
7688        Ok(UntrackedFile {
7689            kind,
7690            len: metadata.len(),
7691            modified: metadata.modified().ok(),
7692            created: metadata.created().ok(),
7693            readonly: metadata.permissions().readonly(),
7694            symlink_target,
7695            device: metadata.dev(),
7696            inode: metadata.ino(),
7697            mode: metadata.mode(),
7698            change_seconds: metadata.ctime(),
7699            change_nanoseconds: metadata.ctime_nsec(),
7700        })
7701    }
7702    #[cfg(not(unix))]
7703    {
7704        Ok(UntrackedFile {
7705            kind,
7706            len: metadata.len(),
7707            modified: metadata.modified().ok(),
7708            created: metadata.created().ok(),
7709            readonly: metadata.permissions().readonly(),
7710            symlink_target,
7711        })
7712    }
7713}
7714
7715#[cfg(unix)]
7716fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7717    use std::os::unix::ffi::OsStrExt;
7718    Ok(value.as_bytes().to_vec())
7719}
7720
7721#[cfg(not(unix))]
7722fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7723    value
7724        .to_str()
7725        .map(|value| value.as_bytes().to_vec())
7726        .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
7727}
7728
7729#[cfg(unix)]
7730fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7731    use std::os::unix::fs::MetadataExt;
7732    right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
7733}
7734
7735#[cfg(not(unix))]
7736fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7737    right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
7738}
7739
7740#[derive(Debug, Clone, serde::Serialize, Deserialize)]
7741pub struct BranchRecord {
7742    pub kind: String,
7743    pub number: i64,
7744}
7745
7746/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
7747/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
7748pub fn review_ref(number: i64) -> String {
7749    format!("refs/spar/pr-{number}")
7750}
7751
7752pub fn is_finished(state: &str) -> bool {
7753    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
7754}
7755
7756/// Write text through a temporary file and rename, so a kill cannot leave a
7757/// truncated file behind.
7758///
7759/// The follow-up queue is the one file spar rewrites in place rather than
7760/// appends to, and a truncated queue is lost work: what it held was never
7761/// written anywhere else.
7762pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
7763    if let Some(parent) = path.parent() {
7764        std::fs::create_dir_all(parent)
7765            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
7766    }
7767    // The extension defaults to `json` so `clear_state`, which removes a
7768    // leftover `pr-N.json.tmp` by name, keeps finding the one this wrote.
7769    let tmp = path.with_extension(format!(
7770        "{}.tmp",
7771        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
7772    ));
7773    std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
7774    std::fs::rename(&tmp, path)
7775        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
7776    Ok(())
7777}
7778
7779/// Whether a follow-up file already has an entry under this exact title.
7780///
7781/// Whole heading lines, case insensitively. A substring check answered yes
7782/// for "Retry loop" against "## Retry loop never terminates", which is a
7783/// different defect, and the new one was dropped with a line saying it was
7784/// already noted.
7785pub fn has_heading(text: &str, title: &str) -> bool {
7786    let wanted = title.trim().to_lowercase();
7787    if wanted.is_empty() {
7788        return false;
7789    }
7790    text.lines()
7791        .filter_map(|line| line.trim().strip_prefix("## "))
7792        .any(|heading| heading.trim().to_lowercase() == wanted)
7793}
7794
7795/// Write JSON through a temporary file and rename, so a kill cannot leave a
7796/// truncated state file behind.
7797pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
7798    write_text_atomic(path, &serde_json::to_string_pretty(value)?)
7799}
7800
7801/// One row of `gh pr list --json number,url,title,baseRefName,closingIssuesReferences`.
7802#[derive(Deserialize)]
7803#[serde(rename_all = "camelCase")]
7804pub struct LinkedPrRow {
7805    number: i64,
7806    #[serde(default)]
7807    url: String,
7808    #[serde(default)]
7809    title: String,
7810    #[serde(default)]
7811    closing_issues_references: Vec<IssueRef>,
7812}
7813
7814/// The listed pull requests, or `None` when the payload could not be read.
7815///
7816/// Separated from the gh call so the real payload shape can be tested, and kept
7817/// distinct from an empty list, because silently failing to parse would look
7818/// exactly like "no pull request exists", which is the answer that makes spar
7819/// implement over the top of somebody's work.
7820pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
7821    serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
7822}
7823
7824/// Among the listed pull requests, the first that would close `issue`.
7825pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
7826    rows.iter()
7827        .find(|row| {
7828            row.closing_issues_references
7829                .iter()
7830                .any(|linked| linked.number == issue)
7831        })
7832        .map(|row| PrRef {
7833            number: row.number,
7834            url: row.url.clone(),
7835            title: row.title.clone(),
7836        })
7837}
7838
7839/// Both halves together, for the tests that hold the real payload.
7840pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
7841    pick_linked_pr(&linked_pr_rows(json)?, issue)
7842}
7843
7844/// Flatten whatever `gh api --paginate` printed into a list of comments.
7845///
7846/// Current gh merges array pages into one array. Older builds concatenated one
7847/// document per page. A streaming parser reads either, and unlike splitting the
7848/// text on a bracket pair it cannot be fooled by a comment body that happens to
7849/// contain one, which would otherwise make a resume silently start over.
7850pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
7851    if text.trim().is_empty() {
7852        return Err(spar_err!("GitHub returned no comment data"));
7853    }
7854    let mut out = Vec::new();
7855    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7856        match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
7857            Value::Array(items) => out.extend(items),
7858            _ => return Err(spar_err!("unexpected non-array comment page")),
7859        }
7860    }
7861    Ok(out)
7862}
7863
7864pub fn parse_comment_pages(text: &str) -> Vec<Value> {
7865    let mut out = Vec::new();
7866    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7867        match value {
7868            Ok(Value::Array(items)) => out.extend(items),
7869            Ok(other) => out.push(other),
7870            Err(_) => break,
7871        }
7872    }
7873    out
7874}
7875
7876/// Whether a comment is a state block spar itself wrote.
7877///
7878/// The body has to open with the marker, so a person quoting it in a sentence
7879/// is not one; it has to parse, so a half-quoted block is not one; and it has
7880/// to be the viewer's own comment, the way `comments.rs` already tells spar's
7881/// writes apart. Anything less and `write_pr_state` patches somebody's comment
7882/// with JSON, and `spar clean --pr-state` later deletes it.
7883pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
7884    body.trim_start().starts_with(STATE_MARKER)
7885        && !viewer.trim().is_empty()
7886        && author.trim().eq_ignore_ascii_case(viewer.trim())
7887        && parse_state_comment(body).is_some()
7888}
7889
7890/// Extract the payload from a state comment. The marker is followed by JSON and
7891/// terminated with `-->`.
7892pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
7893    let marker = body.find(STATE_MARKER)?;
7894    let start = body[marker..].find('{')? + marker;
7895    let end = body.rfind('}')?;
7896    if end <= start {
7897        return None;
7898    }
7899    match serde_json::from_str(&body[start..=end]) {
7900        Ok(state) => Some(state),
7901        Err(_) => {
7902            logdim!("found a spar state comment but could not parse it");
7903            None
7904        }
7905    }
7906}
7907
7908fn choose_state_for_head(
7909    candidates: Vec<PersistedState>,
7910    actual_head: &str,
7911) -> Option<PersistedState> {
7912    let matching: Vec<PersistedState> = candidates
7913        .iter()
7914        .filter(|state| state.pr_head == actual_head)
7915        .cloned()
7916        .collect();
7917    if !matching.is_empty() {
7918        return newest_state(matching);
7919    }
7920    newest_state(candidates)
7921}
7922
7923fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
7924    candidates.into_iter().reduce(|best, candidate| {
7925        if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
7926            candidate
7927        } else {
7928            // The local candidate is supplied first. Keeping the first exact
7929            // tie recovers correctly from a local write followed by a failed
7930            // pull request state update, including legacy states with no
7931            // checkpoint field.
7932            best
7933        }
7934    })
7935}
7936
7937/// Where this binary lives, so `git filter-branch` can call back into it.
7938///
7939/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
7940/// tests, whose `current_exe` is the test harness rather than spar, and for
7941/// anyone who ships spar behind a wrapper script.
7942pub fn self_binary() -> Result<PathBuf> {
7943    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
7944        let path = PathBuf::from(path);
7945        if proc::is_executable(&path) {
7946            return Ok(path);
7947        }
7948        bail!(
7949            "SPAR_SELF_BIN is set to {}, which is not executable",
7950            path.display()
7951        );
7952    }
7953    std::env::current_exe()
7954        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
7955}
7956
7957fn bool_env(value: bool) -> &'static str {
7958    if value {
7959        "1"
7960    } else {
7961        "0"
7962    }
7963}
7964
7965/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
7966/// shell command, and an install path with a space in it is not exotic.
7967pub fn sh_quote(text: &str) -> String {
7968    format!("'{}'", text.replace('\'', r"'\''"))
7969}
7970
7971/// Style rules for the `scrub-filter` subcommand, which runs in a child process
7972/// spawned by git and so cannot see the parent's config.
7973pub fn style_from_env() -> Style {
7974    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
7975    Style {
7976        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
7977        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
7978        ..Style::permissive()
7979    }
7980}
7981
7982#[cfg(test)]
7983mod tests {
7984    use super::*;
7985    use crate::config::StateStore;
7986    use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
7987    use std::process::Command;
7988
7989    fn repo_for_titles() -> Repo {
7990        Repo {
7991            root: PathBuf::from("/nonexistent"),
7992            style: Style::default(),
7993            branch_prefix: String::new(),
7994            state_store: StateStore::Local,
7995            followups: crate::config::Followups::Issues,
7996            drafts: Drafts::Never,
7997            viewer: OnceLock::new(),
7998            checkpoints: Mutex::new(BTreeMap::new()),
7999            writes: WriteStats::default(),
8000        }
8001    }
8002
8003    #[test]
8004    fn write_results_accumulate_for_the_run() {
8005        let repo = repo_for_titles();
8006
8007        let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
8008        let _: std::result::Result<(), ()> = repo.record_write(Err(()));
8009
8010        assert_eq!(
8011            WriteSummary {
8012                attempted: 2,
8013                failed: 1,
8014            },
8015            repo.write_summary()
8016        );
8017    }
8018
8019    #[test]
8020    fn only_failed_write_preflights_join_the_summary() {
8021        let repo = repo_for_titles();
8022
8023        let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
8024        let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
8025
8026        assert_eq!(
8027            WriteSummary {
8028                attempted: 1,
8029                failed: 1,
8030            },
8031            repo.write_summary()
8032        );
8033    }
8034
8035    #[test]
8036    fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
8037        let repo = repo_for_titles();
8038
8039        assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
8040        assert_eq!(
8041            WriteSummary {
8042                attempted: 1,
8043                failed: 1,
8044            },
8045            repo.write_summary()
8046        );
8047    }
8048
8049    #[test]
8050    fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
8051        let mut repo = repo_for_titles();
8052        repo.followups = Followups::Local;
8053
8054        assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
8055        assert_eq!(WriteSummary::default(), repo.write_summary());
8056    }
8057
8058    #[test]
8059    fn a_failed_remote_state_read_stops_before_state_mutation() {
8060        let root = std::env::temp_dir().join(format!(
8061            "spar-state-preflight-{}-{}",
8062            std::process::id(),
8063            std::time::SystemTime::now()
8064                .duration_since(std::time::UNIX_EPOCH)
8065                .unwrap()
8066                .as_nanos()
8067        ));
8068        std::fs::create_dir_all(&root).unwrap();
8069        let _fixture = ReviewFixture { root: root.clone() };
8070        let mut repo = repo_for_titles();
8071        repo.root = root;
8072        repo.state_store = StateStore::Both;
8073        let state = PersistedState {
8074            version: 1,
8075            checkpoint: 4,
8076            round: 2,
8077            next_actor: "a".into(),
8078            status: Status::Pending,
8079            pr_head: "abc123".into(),
8080            ledger: Ledger::new(),
8081            filed: Vec::new(),
8082            open_findings: Vec::new(),
8083            disputes: Vec::new(),
8084            noted: Vec::new(),
8085        };
8086
8087        let error = repo
8088            .write_state_after_remote_read(
8089                7,
8090                &state,
8091                Err(crate::error::SparError::new("state comments unavailable")),
8092            )
8093            .unwrap_err();
8094
8095        assert!(error.to_string().contains("state comments unavailable"));
8096        assert!(!repo.state_path(7).exists());
8097        assert_eq!(0, repo.remembered_checkpoint(7));
8098        assert_eq!(
8099            WriteSummary {
8100                attempted: 1,
8101                failed: 1,
8102            },
8103            repo.write_summary()
8104        );
8105    }
8106
8107    #[test]
8108    fn only_known_build_and_cache_directories_are_generated_artifacts() {
8109        assert!(is_generated_artifact(Path::new("target/debug/artifact")));
8110        assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
8111        assert!(is_generated_artifact(Path::new(
8112            "package/node_modules/dependency/file.js"
8113        )));
8114        assert!(!is_generated_artifact(Path::new(
8115            "distribution/required-package.js"
8116        )));
8117        assert!(!is_generated_artifact(Path::new(
8118            "generated/required-fixture.txt"
8119        )));
8120        assert!(!is_generated_artifact(Path::new("local.env")));
8121    }
8122
8123    #[test]
8124    fn only_finder_state_files_are_desktop_metadata() {
8125        assert!(is_desktop_metadata(Path::new(".DS_Store")));
8126        assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
8127        assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
8128        assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
8129    }
8130
8131    struct ReviewFixture {
8132        root: PathBuf,
8133    }
8134
8135    impl Drop for ReviewFixture {
8136        fn drop(&mut self) {
8137            let _ = std::fs::remove_dir_all(&self.root);
8138        }
8139    }
8140
8141    fn test_git(cwd: &Path, args: &[&str]) -> String {
8142        let output = Command::new("git")
8143            .args(args)
8144            .current_dir(cwd)
8145            .output()
8146            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
8147        assert!(
8148            output.status.success(),
8149            "git {args:?} failed: {}",
8150            String::from_utf8_lossy(&output.stderr)
8151        );
8152        String::from_utf8_lossy(&output.stdout).into_owned()
8153    }
8154
8155    fn review_fixture(
8156        tag: &str,
8157        number: i64,
8158    ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
8159        use std::sync::atomic::{AtomicU32, Ordering};
8160        static NEXT: AtomicU32 = AtomicU32::new(0);
8161        let id = NEXT.fetch_add(1, Ordering::Relaxed);
8162        let root =
8163            std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
8164        let origin = root.join("origin.git");
8165        let work = root.join("work");
8166        std::fs::create_dir_all(&origin).unwrap();
8167        std::fs::create_dir_all(&work).unwrap();
8168        test_git(&origin, &["init", "--bare", "-b", "main"]);
8169        test_git(&work, &["init", "-b", "main"]);
8170        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
8171        test_git(&work, &["config", "user.name", "spar test"]);
8172        test_git(&work, &["config", "commit.gpgsign", "false"]);
8173        test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
8174        test_git(&work, &["config", "filter.drop.smudge", "cat"]);
8175        std::fs::write(work.join("README.md"), "seed\n").unwrap();
8176        std::fs::write(work.join("data.txt"), "old\n").unwrap();
8177        std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
8178        std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
8179        test_git(&work, &["add", "."]);
8180        test_git(&work, &["commit", "-m", "seed"]);
8181        test_git(
8182            &work,
8183            &["remote", "add", "origin", origin.to_str().unwrap()],
8184        );
8185        test_git(&work, &["push", "-u", "origin", "main"]);
8186        test_git(
8187            &work,
8188            &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
8189        );
8190        let cfg = crate::config::parse(
8191            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
8192        )
8193        .unwrap();
8194        let repo = Repo::open(&work, &cfg).unwrap();
8195        let path = repo.worktree_for_pr_head(number).unwrap();
8196        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8197        (ReviewFixture { root }, repo, path, checkpoint)
8198    }
8199
8200    #[test]
8201    fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
8202        let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
8203
8204        repo.release_review_worktree_checked(901, &checkpoint)
8205            .unwrap();
8206
8207        assert!(!path.exists());
8208    }
8209
8210    #[test]
8211    fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
8212        let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
8213        let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
8214        std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
8215        test_git(&path, &["add", "recovery.txt"]);
8216        test_git(&path, &["commit", "-m", "recovery commit"]);
8217        let recovery = test_git(&path, &["rev-parse", "HEAD"]);
8218        test_git(&path, &["reset", "--hard", "main"]);
8219
8220        assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
8221        test_git(
8222            &path,
8223            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
8224        );
8225    }
8226
8227    #[test]
8228    fn a_review_ref_reflog_only_commit_prevents_deletion() {
8229        let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
8230        let local_ref = review_ref(921);
8231        let original = test_git(&path, &["rev-parse", &local_ref]);
8232        let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
8233        let recovery = test_git(
8234            &path,
8235            &[
8236                "commit-tree",
8237                tree.trim(),
8238                "-p",
8239                original.trim(),
8240                "-m",
8241                "review ref recovery",
8242            ],
8243        );
8244        test_git(
8245            &path,
8246            &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
8247        );
8248        test_git(
8249            &path,
8250            &["update-ref", &local_ref, original.trim(), recovery.trim()],
8251        );
8252
8253        assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
8254        assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
8255        test_git(
8256            &path,
8257            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
8258        );
8259    }
8260
8261    #[test]
8262    fn an_unpublished_commit_message_draft_is_recoverable() {
8263        let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
8264        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
8265        let git_dir = if raw.is_absolute() {
8266            raw
8267        } else {
8268            path.join(raw)
8269        };
8270        std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
8271
8272        assert!(repository_has_recoverable_work(&path, true).unwrap());
8273        assert_eq!(
8274            "unique recovery draft\n",
8275            std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
8276        );
8277    }
8278
8279    #[test]
8280    fn a_changed_review_worktree_is_retained_after_a_checked_read() {
8281        let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
8282        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
8283
8284        let error = repo
8285            .release_review_worktree_checked(902, &checkpoint)
8286            .unwrap_err();
8287
8288        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8289        assert!(error.to_string().contains("kept for recovery"), "{error}");
8290        assert_eq!(
8291            "recover me\n",
8292            std::fs::read_to_string(path.join("README.md")).unwrap()
8293        );
8294        repo.release_review_worktree(902);
8295    }
8296
8297    #[test]
8298    fn a_review_commit_is_retained_after_a_checked_read() {
8299        let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
8300        std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
8301        test_git(&path, &["add", "review-note.txt"]);
8302        test_git(&path, &["commit", "-m", "local review recovery"]);
8303        let head = test_git(&path, &["rev-parse", "HEAD"]);
8304
8305        let error = repo
8306            .release_review_worktree_checked(903, &checkpoint)
8307            .unwrap_err();
8308
8309        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8310        assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
8311        assert_eq!(
8312            "recover me\n",
8313            std::fs::read_to_string(path.join("review-note.txt")).unwrap()
8314        );
8315        repo.release_review_worktree(903);
8316    }
8317
8318    /// The read is no longer what refuses: a file the call created and Git was
8319    /// told to ignore is its own output. Removal still refuses, so the file is
8320    /// there to look at, which is the part that mattered.
8321    #[test]
8322    fn an_ignored_review_file_is_retained_after_a_checked_read() {
8323        let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
8324        std::fs::create_dir_all(path.join("generated")).unwrap();
8325        std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
8326
8327        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8328            .expect("its own output is not a change");
8329        let error = repo
8330            .release_review_worktree_checked(904, &checkpoint)
8331            .unwrap_err();
8332
8333        assert!(error.to_string().contains("recoverable"), "{error}");
8334        assert_eq!(
8335            "recover me\n",
8336            std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
8337        );
8338        repo.release_review_worktree(904);
8339    }
8340
8341    #[test]
8342    /// The checkpoint no longer refuses this: a path the project ignores is out
8343    /// of scope for a call that only read. What keeps the file is the gate that
8344    /// actually deletes the checkout, which refuses any ignored file outside a
8345    /// known build directory. That is the check with the stake in it, and it is
8346    /// the one that has to hold.
8347    fn a_preexisting_ignored_review_file_change_is_retained() {
8348        let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
8349        std::fs::create_dir_all(path.join("generated")).unwrap();
8350        let ignored = path.join("generated/recovery.txt");
8351        std::fs::write(&ignored, "before\n").unwrap();
8352        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8353        std::fs::write(&ignored, "after!\n").unwrap();
8354
8355        let error = repo
8356            .release_review_worktree_checked(905, &checkpoint)
8357            .unwrap_err();
8358
8359        assert!(error.to_string().contains("recoverable"), "{error}");
8360        assert!(path.exists());
8361        assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
8362        repo.release_review_worktree(905);
8363    }
8364
8365    #[test]
8366    fn a_preexisting_ignored_review_file_prevents_checked_removal() {
8367        let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
8368        std::fs::create_dir_all(path.join("generated")).unwrap();
8369        let ignored = path.join("generated/recovery.txt");
8370        std::fs::write(&ignored, "keep me\n").unwrap();
8371        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8372
8373        let error = repo
8374            .release_review_worktree_checked(906, &checkpoint)
8375            .unwrap_err();
8376
8377        assert!(error.to_string().contains("recoverable"), "{error}");
8378        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
8379    }
8380
8381    #[test]
8382    fn overwriting_a_preexisting_untracked_file_is_detected() {
8383        let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
8384        let untracked = path.join("notes.txt");
8385        std::fs::write(&untracked, "before\n").unwrap();
8386        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8387        std::fs::write(&untracked, "after!\n").unwrap();
8388
8389        let error = repo
8390            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8391            .unwrap_err();
8392
8393        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8394        assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
8395    }
8396
8397    #[test]
8398    fn an_assume_unchanged_edit_is_detected() {
8399        let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
8400        test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
8401        std::fs::write(path.join("README.md"), "hidden\n").unwrap();
8402
8403        let error = repo
8404            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8405            .unwrap_err();
8406
8407        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8408        assert_eq!(
8409            "hidden\n",
8410            std::fs::read_to_string(path.join("README.md")).unwrap()
8411        );
8412    }
8413
8414    #[test]
8415    fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
8416        let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
8417        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
8418        test_git(&path, &["add", "README.md"]);
8419        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8420
8421        let error = repo
8422            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8423            .unwrap_err();
8424
8425        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8426        assert_eq!(
8427            b"seed\r\n",
8428            std::fs::read(path.join("README.md")).unwrap().as_slice()
8429        );
8430    }
8431
8432    #[cfg(unix)]
8433    #[test]
8434    fn a_mode_edit_is_detected_when_filemode_is_disabled() {
8435        use std::os::unix::fs::PermissionsExt;
8436
8437        let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
8438        test_git(&path, &["config", "core.filemode", "false"]);
8439        let readme = path.join("README.md");
8440        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
8441        permissions.set_mode(0o755);
8442        std::fs::set_permissions(&readme, permissions).unwrap();
8443        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8444
8445        let error = repo
8446            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8447            .unwrap_err();
8448
8449        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8450        assert_eq!(
8451            0o755,
8452            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
8453        );
8454    }
8455
8456    #[test]
8457    fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
8458        let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
8459        std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
8460        test_git(&path, &["add", ".gitattributes"]);
8461        test_git(&path, &["commit", "-m", "select data filter"]);
8462        let baseline = repo.worktree_baseline(&path).unwrap();
8463        std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
8464
8465        assert!(repo
8466            .commit_pending_changes(&path, &baseline, "change data", "change data")
8467            .unwrap());
8468        let error = repo
8469            .refuse_unrepresented_tracked_changes(&path, &baseline)
8470            .unwrap_err();
8471
8472        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8473        assert_eq!(
8474            "secret: recover me\nnew\n",
8475            std::fs::read_to_string(path.join("data.txt")).unwrap()
8476        );
8477        assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
8478    }
8479
8480    #[test]
8481    fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
8482        let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
8483        std::fs::create_dir_all(path.join("target")).unwrap();
8484        let untracked = path.join("target/user.yaml");
8485        std::fs::write(&untracked, "user data\n").unwrap();
8486        let baseline = repo.worktree_baseline(&path).unwrap();
8487        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8488
8489        assert!(repo
8490            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8491            .unwrap());
8492
8493        assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
8494        assert_eq!(
8495            "?? target/user.yaml\n",
8496            test_git(&path, &["status", "--short", "--untracked-files=all"])
8497        );
8498        assert!(test_git(
8499            &path,
8500            &[
8501                "ls-tree",
8502                "-r",
8503                "--name-only",
8504                "HEAD",
8505                "--",
8506                "target/user.yaml"
8507            ]
8508        )
8509        .is_empty());
8510    }
8511
8512    #[test]
8513    fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
8514        let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
8515        std::fs::create_dir_all(path.join("target")).unwrap();
8516        let untracked = path.join("target/user.yaml");
8517        std::fs::write(&untracked, "before\n").unwrap();
8518        let baseline = repo.worktree_baseline(&path).unwrap();
8519        let before = test_git(&path, &["rev-parse", "HEAD"]);
8520        std::fs::write(&untracked, "after\n").unwrap();
8521        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8522
8523        let error = repo
8524            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8525            .unwrap_err();
8526
8527        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8528        assert!(error.to_string().contains("target/user.yaml"), "{error}");
8529        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
8530        assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
8531        assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
8532    }
8533
8534    #[test]
8535    fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
8536        let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
8537        let baseline = repo.worktree_baseline(&path).unwrap();
8538        std::fs::create_dir_all(path.join("target")).unwrap();
8539        std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
8540
8541        assert!(repo
8542            .commit_pending_changes(&path, &baseline, "add file", "add file")
8543            .unwrap());
8544
8545        assert_eq!(
8546            "new file\n",
8547            test_git(&path, &["show", "HEAD:target/new.txt"])
8548        );
8549        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8550    }
8551
8552    #[test]
8553    fn a_managed_commit_accepts_a_custom_output_rebuild() {
8554        let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
8555        exclude_paths(&repo, &["manager/public/"]);
8556        let output = path.join("manager/public/assets");
8557        std::fs::create_dir_all(&output).unwrap();
8558        std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
8559        std::fs::write(output.join("index.css"), "old styles\n").unwrap();
8560        let baseline = repo.worktree_baseline(&path).unwrap();
8561        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8562        std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
8563        std::fs::remove_file(output.join("old.js")).unwrap();
8564        std::fs::write(output.join("index.css"), "new styles\n").unwrap();
8565        std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
8566
8567        assert!(repo
8568            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8569            .unwrap());
8570        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8571        repo.refuse_changed_existing_untracked(&path, &baseline)
8572            .unwrap();
8573        repo.refuse_unrepresented_tracked_changes(&path, &baseline)
8574            .unwrap();
8575
8576        assert_eq!(
8577            "tracked change\n",
8578            test_git(&path, &["show", "HEAD:README.md"])
8579        );
8580        assert_eq!(
8581            "new source\n",
8582            test_git(&path, &["show", "HEAD:new-source.txt"])
8583        );
8584        assert!(test_git(
8585            &path,
8586            &[
8587                "ls-tree",
8588                "-r",
8589                "--name-only",
8590                "HEAD",
8591                "--",
8592                "manager/public"
8593            ]
8594        )
8595        .is_empty());
8596        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8597        assert!(!output.join("old.js").exists());
8598        assert_eq!(
8599            "new styles\n",
8600            std::fs::read_to_string(output.join("index.css")).unwrap()
8601        );
8602        assert!(repository_has_recoverable_work(&path, true).unwrap());
8603        repo.release_review_worktree(912);
8604        assert!(output.join("index-DUym-Rfj.js").exists());
8605    }
8606
8607    #[test]
8608    fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
8609        let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
8610        std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
8611        let baseline = repo.worktree_baseline(&path).unwrap();
8612        std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
8613        std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
8614
8615        let error = repo
8616            .commit_pending_changes(&path, &baseline, "change", "change")
8617            .unwrap_err();
8618
8619        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8620        assert!(error.to_string().contains("notes.txt"), "{error}");
8621    }
8622
8623    #[test]
8624    fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
8625        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
8626        let baseline = repo.worktree_baseline(&path).unwrap();
8627        let nested = path.join("generated/repro");
8628        std::fs::create_dir_all(&nested).unwrap();
8629        test_git(&nested, &["init"]);
8630        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8631
8632        assert!(repo
8633            .commit_pending_changes(&path, &baseline, "change", "change")
8634            .unwrap());
8635        assert!(!repo.has_uncommitted_changes(&path).unwrap());
8636        assert!(repository_has_recoverable_work(&path, true).unwrap());
8637        repo.release_review_worktree(927);
8638        assert!(nested.join(".git").exists());
8639    }
8640
8641    #[test]
8642    fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
8643        let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
8644        test_git(&path, &["config", "core.autocrlf", "true"]);
8645        std::fs::write(path.join("README.md"), "seed\n").unwrap();
8646        assert_eq!(
8647            test_git(&path, &["hash-object", "README.md"]).trim(),
8648            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8649        );
8650
8651        assert!(repository_has_recoverable_work(&path, true).unwrap());
8652        assert_eq!(
8653            "seed\n",
8654            std::fs::read_to_string(path.join("README.md")).unwrap()
8655        );
8656    }
8657
8658    #[test]
8659    fn autocrlf_input_overrides_a_crlf_core_eol() {
8660        let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
8661        test_git(&path, &["config", "core.autocrlf", "input"]);
8662        test_git(&path, &["config", "core.eol", "crlf"]);
8663        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
8664        assert_eq!(
8665            test_git(&path, &["hash-object", "README.md"]).trim(),
8666            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8667        );
8668
8669        assert!(repository_has_recoverable_work(&path, true).unwrap());
8670        assert_eq!(
8671            b"seed\r\n",
8672            std::fs::read(path.join("README.md")).unwrap().as_slice()
8673        );
8674    }
8675
8676    #[cfg(unix)]
8677    #[test]
8678    fn a_non_executable_permission_change_is_recoverable() {
8679        use std::os::unix::fs::PermissionsExt;
8680
8681        let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
8682        let readme = path.join("README.md");
8683        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
8684        permissions.set_mode(0o600);
8685        std::fs::set_permissions(&readme, permissions).unwrap();
8686        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8687
8688        let error = repo
8689            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8690            .unwrap_err();
8691
8692        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8693        assert!(repository_has_recoverable_work(&path, true).unwrap());
8694        assert_eq!(
8695            0o600,
8696            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
8697        );
8698    }
8699
8700    #[cfg(unix)]
8701    #[test]
8702    fn a_managed_commit_skips_signing_and_hooks() {
8703        use std::os::unix::fs::PermissionsExt;
8704
8705        let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
8706        let common = common_git_dir(&path).unwrap();
8707        let hook = common.join("hooks/pre-commit");
8708        let marker = fixture.root.join("hook-ran");
8709        std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
8710        std::fs::write(
8711            &hook,
8712            format!(
8713                "#!/bin/sh\nprintf ran > {}\nexit 1\n",
8714                sh_quote(marker.to_str().unwrap())
8715            ),
8716        )
8717        .unwrap();
8718        let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
8719        permissions.set_mode(0o755);
8720        std::fs::set_permissions(&hook, permissions).unwrap();
8721        test_git(&path, &["config", "commit.gpgsign", "true"]);
8722        test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
8723        std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
8724        test_git(&path, &["add", "managed.txt"]);
8725
8726        repo.commit_staged_changes(&path, "record managed change")
8727            .unwrap();
8728
8729        assert!(!marker.exists());
8730        assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
8731    }
8732
8733    #[test]
8734    fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
8735        let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
8736        std::fs::write(
8737            path.join(".gitattributes"),
8738            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
8739        )
8740        .unwrap();
8741        test_git(&path, &["add", ".gitattributes"]);
8742        test_git(&path, &["commit", "-m", "select automatic text"]);
8743        test_git(&path, &["config", "core.autocrlf", "true"]);
8744
8745        assert!(repository_has_recoverable_work(&path, true).unwrap());
8746    }
8747
8748    #[test]
8749    fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
8750        let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
8751        std::fs::write(
8752            path.join(".gitattributes"),
8753            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
8754        )
8755        .unwrap();
8756        test_git(&path, &["add", ".gitattributes"]);
8757        test_git(&path, &["commit", "-m", "select ident expansion"]);
8758        std::fs::write(path.join("README.md"), "seed\n").unwrap();
8759
8760        assert!(repository_has_recoverable_work(&path, true).unwrap());
8761    }
8762
8763    /// Add ignore rules the way SPAR does, without a tracked change the
8764    /// worktree would then be kept for.
8765    /// The failure this whole rule was rewritten for: a reviewer ran the
8766    /// project's binding generator to check a finding, and the lockfile it left
8767    /// beside its own manifest matched no directory name spar knew.
8768    #[test]
8769    fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
8770        let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
8771        exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
8772        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8773        std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
8774        std::fs::write(
8775            path.join("bindings/uniffi-bindgen/Cargo.lock"),
8776            "# generated\n",
8777        )
8778        .unwrap();
8779
8780        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8781            .expect("a path the project ignores is the call's own output");
8782    }
8783
8784    #[test]
8785    fn an_attribute_file_under_build_output_is_not_a_filter_change() {
8786        let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
8787        exclude_paths(&repo, &["target/"]);
8788        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8789        std::fs::create_dir_all(path.join("target/package")).unwrap();
8790        std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
8791
8792        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8793            .expect("an ignored attribute file governs only ignored paths");
8794        assert!(
8795            !repo.has_uncommitted_changes(&path).unwrap(),
8796            "ignored dependency attributes must not stop the next review"
8797        );
8798    }
8799
8800    #[test]
8801    fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
8802        let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
8803        std::fs::create_dir_all(path.join("source")).unwrap();
8804        std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
8805
8806        assert!(repo.has_uncommitted_changes(&path).unwrap());
8807    }
8808
8809    #[test]
8810    fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
8811        for source in ["repository", "nested", "local", "global"] {
8812            let (_fixture, repo, path, _initial) = review_fixture(source, 945);
8813            std::fs::create_dir_all(path.join("manager")).unwrap();
8814            match source {
8815                "repository" => {
8816                    std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
8817                        .unwrap();
8818                    test_git(&path, &["add", ".gitignore"]);
8819                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
8820                }
8821                "nested" => {
8822                    std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
8823                    test_git(&path, &["add", "manager/.gitignore"]);
8824                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
8825                }
8826                "local" => exclude_paths(&repo, &["manager/scratch/"]),
8827                "global" => {
8828                    let excludes = repo.root().parent().unwrap().join("global-ignore");
8829                    std::fs::write(&excludes, "manager/scratch/\n").unwrap();
8830                    test_git(
8831                        &path,
8832                        &["config", "core.excludesFile", excludes.to_str().unwrap()],
8833                    );
8834                }
8835                _ => unreachable!(),
8836            }
8837            let output = path.join("manager/scratch");
8838            std::fs::create_dir_all(&output).unwrap();
8839            std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
8840            std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
8841            let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8842            std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
8843            std::fs::remove_file(output.join(".DS_Store")).unwrap();
8844            for file in [
8845                ".gitattributes",
8846                "Cargo.lock",
8847                "font.woff2",
8848                "bundle.js",
8849                "cache.db",
8850            ] {
8851                std::fs::write(output.join(file), "generated\n").unwrap();
8852            }
8853            #[cfg(unix)]
8854            std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
8855            let nested = output.join("dependency");
8856            std::fs::create_dir_all(&nested).unwrap();
8857            test_git(&nested, &["init"]);
8858            repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8859                .unwrap_or_else(|error| panic!("{source}: {error}"));
8860            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
8861
8862            let baseline = repo.worktree_baseline(&path).unwrap();
8863            std::fs::write(path.join("README.md"), "source fix\n").unwrap();
8864            std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
8865            std::fs::remove_file(output.join("Cargo.lock")).unwrap();
8866            std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
8867            std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
8868            assert!(repo
8869                .commit_pending_changes(&path, &baseline, "fix source", "fix source")
8870                .unwrap_or_else(|error| panic!("{source}: {error}")));
8871            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
8872            assert!(test_git(
8873                &path,
8874                &[
8875                    "ls-tree",
8876                    "-r",
8877                    "--name-only",
8878                    "HEAD",
8879                    "--",
8880                    "manager/scratch"
8881                ]
8882            )
8883            .is_empty());
8884            assert!(
8885                repository_has_recoverable_work(&path, true).unwrap(),
8886                "{source}"
8887            );
8888            repo.release_review_worktree(945);
8889            assert_eq!(
8890                "LOCAL=updated\n",
8891                std::fs::read_to_string(output.join(".env.local")).unwrap()
8892            );
8893            assert!(nested.join(".git").exists(), "{source}");
8894        }
8895    }
8896
8897    /// Two reviewers share one checkout. The marker written when the first
8898    /// one's answer was refused used to be a brand new untracked file in the
8899    /// snapshot the second one was compared against.
8900    #[test]
8901    fn a_recovery_marker_is_not_a_change_to_the_worktree() {
8902        let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
8903        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8904        let _ = uncertain_worktree_change(&path, "an earlier call was refused");
8905
8906        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8907            .expect("spar's own marker is not something a call did");
8908    }
8909
8910    #[test]
8911    fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
8912        let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
8913
8914        let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
8915            .expect("a path a build removed is not an unreadable worktree");
8916
8917        assert_eq!(vanished_untracked_file(), gone);
8918    }
8919
8920    /// A path that goes is still a path that was there. Dropping it from the
8921    /// snapshot would let it pass for one the call never touched.
8922    #[test]
8923    fn a_vanished_path_is_not_recorded_as_absent() {
8924        let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
8925        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
8926        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8927        std::fs::remove_file(path.join("notes.txt")).unwrap();
8928
8929        let error = repo
8930            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8931            .unwrap_err();
8932
8933        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8934    }
8935
8936    fn exclude_paths(repo: &Repo, lines: &[&str]) {
8937        use std::io::Write;
8938        let path = repo.root().join(".git").join("info").join("exclude");
8939        let mut file = std::fs::OpenOptions::new()
8940            .create(true)
8941            .append(true)
8942            .open(&path)
8943            .unwrap();
8944        for line in lines {
8945            writeln!(file, "{line}").unwrap();
8946        }
8947    }
8948
8949    #[test]
8950    fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
8951        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
8952        exclude_paths(&repo, &["dist/"]);
8953        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8954        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8955        let baseline = repo.worktree_baseline(&path).unwrap();
8956        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8957        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8958        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8959        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8960
8961        assert!(repo
8962            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8963            .unwrap());
8964        assert_eq!(
8965            "tracked change\n",
8966            std::fs::read_to_string(path.join("README.md")).unwrap()
8967        );
8968    }
8969
8970    #[test]
8971    fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
8972        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
8973        exclude_paths(&repo, &["dist/"]);
8974        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8975        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8976        std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
8977        let baseline = repo.worktree_baseline(&path).unwrap();
8978        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8979        std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
8980        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8981        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8982
8983        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8984        repo.refuse_changed_existing_untracked(&path, &baseline)
8985            .unwrap();
8986    }
8987
8988    #[test]
8989    fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
8990        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
8991        exclude_paths(&repo, &["dist/"]);
8992        std::fs::create_dir_all(path.join("dist")).unwrap();
8993        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8994        let baseline = repo.worktree_baseline(&path).unwrap();
8995        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8996        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8997
8998        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8999        repo.refuse_changed_existing_untracked(&path, &baseline)
9000            .unwrap();
9001    }
9002
9003    /// The run this comes from: a reviewer rebuilt a project whose build output
9004    /// lands in `manager/public/assets/`, which no directory list will ever
9005    /// name. The guard inside the call kept the review. This check then called
9006    /// the same thirteen files unrepresentable and ended the round, five
9007    /// minutes of review thrown away over a path Git had been told to disown.
9008    /// The run this comes from: `gh pr merge --delete-branch` could not delete
9009    /// `issue-784` because `.spar-worktrees/issue-784`, left by the run that
9010    /// opened the pull request, still had it checked out.
9011    #[test]
9012    fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
9013        let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
9014        let held = repo.root().join(WORKTREE_DIR).join("issue-950");
9015        std::fs::create_dir_all(held.parent().unwrap()).unwrap();
9016        test_git(
9017            repo.root(),
9018            &[
9019                "worktree",
9020                "add",
9021                "-b",
9022                "issue-950",
9023                held.to_str().unwrap(),
9024                "main",
9025            ],
9026        );
9027
9028        let found = repo
9029            .worktree_holding("issue-950")
9030            .expect("the branch is checked out somewhere");
9031
9032        assert!(found.ends_with("issue-950"), "{}", found.display());
9033        assert_eq!(None, repo.worktree_holding("issue-951"));
9034    }
9035
9036    #[test]
9037    fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
9038        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
9039        exclude_paths(&repo, &["manager/public/assets/"]);
9040        std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
9041        let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
9042        std::fs::write(&bundle, "first build\n").unwrap();
9043        let baseline = repo.worktree_baseline(&path).unwrap();
9044        std::fs::write(&bundle, "second build\n").unwrap();
9045        std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
9046
9047        repo.refuse_read_only_leavings(&path, &baseline).unwrap();
9048        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9049    }
9050
9051    #[test]
9052    fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
9053        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
9054        exclude_paths(&repo, &["dist/"]);
9055        let baseline = repo.worktree_baseline(&path).unwrap();
9056        std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
9057
9058        let error = repo
9059            .refuse_read_only_leavings(&path, &baseline)
9060            .unwrap_err();
9061
9062        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9063        assert!(error.to_string().contains("scratch.md"), "{error}");
9064    }
9065
9066    #[test]
9067    fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
9068        let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
9069        exclude_paths(&repo, &["dist/", ".env.local"]);
9070        std::fs::create_dir_all(path.join("dist")).unwrap();
9071        let baseline = repo.worktree_baseline(&path).unwrap();
9072        std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
9073        std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
9074
9075        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9076        assert!(repository_has_recoverable_work(&path, true).unwrap());
9077    }
9078
9079    #[test]
9080    fn a_read_only_inspection_may_rebuild_generated_output() {
9081        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
9082        exclude_paths(&repo, &["dist/"]);
9083        std::fs::create_dir_all(path.join("dist")).unwrap();
9084        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
9085        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9086        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
9087        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
9088
9089        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9090            .unwrap();
9091    }
9092
9093    #[test]
9094    fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
9095        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
9096        exclude_paths(&repo, &["dist/", ".env.local"]);
9097        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
9098        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9099        std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
9100
9101        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9102            .unwrap();
9103    }
9104
9105    #[test]
9106    fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
9107        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
9108        exclude_paths(&repo, &["dist/", ".env.local"]);
9109        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
9110        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9111        std::fs::remove_file(path.join(".env.local")).unwrap();
9112
9113        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9114            .unwrap();
9115    }
9116
9117    #[test]
9118    fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
9119        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
9120        exclude_paths(&repo, &["dist/"]);
9121        std::fs::write(path.join("notes.md"), "before\n").unwrap();
9122        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9123        std::fs::write(path.join("notes.md"), "after\n").unwrap();
9124
9125        let error = repo
9126            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9127            .unwrap_err();
9128
9129        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9130        assert!(error.to_string().contains("notes.md"), "{error}");
9131    }
9132
9133    #[test]
9134    fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
9135        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
9136        exclude_paths(&repo, &["dist/", "local.env"]);
9137        std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
9138        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9139        // Stop ignoring it, so the same bytes are now an ordinary untracked
9140        // file. What a path is counts, not only whether it moved.
9141        std::fs::write(
9142            repo.root().join(".git").join("info").join("exclude"),
9143            "dist/\n",
9144        )
9145        .unwrap();
9146
9147        let error = repo
9148            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9149            .unwrap_err();
9150
9151        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9152        assert!(error.to_string().contains("local.env"), "{error}");
9153    }
9154
9155    #[test]
9156    fn a_read_only_inspection_names_the_tracked_file_that_moved() {
9157        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
9158        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9159        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9160
9161        let error = repo
9162            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9163            .unwrap_err();
9164
9165        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9166        assert!(error.to_string().contains("README.md"), "{error}");
9167    }
9168
9169    #[test]
9170    fn build_output_alone_does_not_keep_a_worktree() {
9171        let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
9172        exclude_paths(&repo, &["target/", "dist/"]);
9173        std::fs::create_dir_all(path.join("target/debug")).unwrap();
9174        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
9175        std::fs::create_dir_all(path.join("dist/cli")).unwrap();
9176        std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
9177
9178        assert!(!repository_has_recoverable_work(&path, true).unwrap());
9179        repo.release_review_worktree(933);
9180
9181        assert!(!path.exists());
9182    }
9183
9184    #[test]
9185    fn finder_metadata_alone_does_not_keep_a_worktree() {
9186        let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
9187        exclude_paths(&repo, &[".DS_Store"]);
9188        std::fs::create_dir_all(path.join("src")).unwrap();
9189        std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
9190        std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
9191
9192        assert!(!repository_has_recoverable_work(&path, true).unwrap());
9193        repo.release_review_worktree(937);
9194
9195        assert!(!path.exists());
9196    }
9197
9198    #[test]
9199    fn a_fetch_record_alone_does_not_keep_a_worktree() {
9200        let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
9201        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9202        let git_dir = if raw.is_absolute() {
9203            raw
9204        } else {
9205            path.join(raw)
9206        };
9207        let head = test_git(&path, &["rev-parse", "HEAD"]);
9208        std::fs::write(
9209            git_dir.join("FETCH_HEAD"),
9210            format!("{}\t\tbranch 'main' of origin\n", head.trim()),
9211        )
9212        .unwrap();
9213
9214        assert!(!repository_has_recoverable_work(&path, true).unwrap());
9215        repo.release_review_worktree(938);
9216
9217        assert!(!path.exists());
9218    }
9219
9220    #[test]
9221    fn an_ignored_file_outside_build_output_keeps_a_worktree() {
9222        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
9223        exclude_paths(&repo, &["target/", ".env.local"]);
9224        std::fs::create_dir_all(path.join("target/debug")).unwrap();
9225        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
9226        std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
9227
9228        assert!(repository_has_recoverable_work(&path, true).unwrap());
9229        repo.release_review_worktree(934);
9230
9231        assert_eq!(
9232            "TOKEN=keep me\n",
9233            std::fs::read_to_string(path.join(".env.local")).unwrap()
9234        );
9235    }
9236
9237    #[test]
9238    fn a_repository_nested_in_build_output_keeps_a_worktree() {
9239        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
9240        exclude_paths(&repo, &["node_modules/"]);
9241        let nested = path.join("node_modules/local-dep");
9242        std::fs::create_dir_all(&nested).unwrap();
9243        test_git(&nested, &["init"]);
9244        std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
9245
9246        assert!(repository_has_recoverable_work(&path, true).unwrap());
9247        repo.release_review_worktree(935);
9248
9249        assert!(nested.join(".git").exists());
9250    }
9251
9252    #[test]
9253    fn an_ordinary_untracked_file_keeps_a_worktree() {
9254        let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
9255        std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
9256
9257        assert!(repository_has_recoverable_work(&path, true).unwrap());
9258        repo.release_review_worktree(936);
9259
9260        assert_eq!(
9261            "somebody's notes\n",
9262            std::fs::read_to_string(path.join("notes.md")).unwrap()
9263        );
9264    }
9265
9266    #[test]
9267    fn a_legacy_crlf_checkout_is_retained_conservatively() {
9268        let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
9269        std::fs::write(
9270            path.join(".gitattributes"),
9271            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
9272        )
9273        .unwrap();
9274        test_git(&path, &["add", ".gitattributes"]);
9275        test_git(&path, &["commit", "-m", "select legacy line endings"]);
9276
9277        assert!(repository_has_recoverable_work(&path, true).unwrap());
9278    }
9279
9280    #[test]
9281    fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
9282        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
9283        let nested = path.join("tracked");
9284        std::fs::create_dir_all(&nested).unwrap();
9285        std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
9286        test_git(&path, &["add", "tracked/seed.txt"]);
9287        test_git(&path, &["commit", "-m", "add tracked directory"]);
9288        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9289        test_git(&nested, &["init"]);
9290
9291        let error = repo
9292            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9293            .unwrap_err();
9294
9295        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9296        assert!(error.to_string().contains("Git entry"), "{error}");
9297        assert!(nested.join(".git").exists());
9298    }
9299
9300    /// A reviewer that clones a reproduction into ignored build output used to
9301    /// lose the whole review. The state check refused every Git entry it had
9302    /// not put there, including the one Git reports as a nested checkout and
9303    /// the untracked snapshot already records by identity.
9304    #[test]
9305    fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
9306        let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
9307        exclude_paths(&repo, &["target/"]);
9308        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9309        let repro = path.join("target/pr918-repro");
9310        std::fs::create_dir_all(&repro).unwrap();
9311        test_git(&repro, &["init"]);
9312        std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
9313
9314        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9315            .unwrap();
9316
9317        assert!(repository_has_recoverable_work(&path, true).unwrap());
9318        assert!(repro.join(".git").exists());
9319    }
9320
9321    /// What sits under a nested checkout is that repository's business, so the
9322    /// scan stops at its directory rather than reading a checkout of its own
9323    /// as an entry the outer worktree has to account for.
9324    #[test]
9325    fn a_repository_inside_a_nested_checkout_is_left_alone() {
9326        let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
9327        exclude_paths(&repo, &["target/"]);
9328        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9329        let repro = path.join("target/repro");
9330        let vendored = repro.join("vendor/dep");
9331        std::fs::create_dir_all(&vendored).unwrap();
9332        test_git(&repro, &["init"]);
9333        test_git(&vendored, &["init"]);
9334
9335        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9336            .unwrap();
9337
9338        assert!(vendored.join(".git").exists());
9339    }
9340
9341    #[test]
9342    fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
9343        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
9344
9345        let state = ignored_untracked_state(repo.root()).unwrap();
9346
9347        let relative = path.strip_prefix(repo.root()).unwrap();
9348        assert!(
9349            state.files.contains_key(relative),
9350            "{:?}",
9351            state.files.keys().collect::<Vec<_>>()
9352        );
9353        assert!(state.is_ignored(relative));
9354    }
9355
9356    #[test]
9357    fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
9358        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
9359        let baseline = repo.worktree_baseline(repo.root()).unwrap();
9360        std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
9361        std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
9362
9363        repo.refuse_new_ignored_files(repo.root(), &baseline)
9364            .unwrap();
9365        repo.refuse_changed_existing_untracked(repo.root(), &baseline)
9366            .unwrap();
9367    }
9368
9369    #[test]
9370    fn deleting_a_resident_worktree_during_a_call_is_refused() {
9371        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
9372        let baseline = repo.worktree_baseline(repo.root()).unwrap();
9373        let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
9374        std::fs::remove_dir_all(&path).unwrap();
9375
9376        let read_error = repo
9377            .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
9378            .unwrap_err();
9379        assert!(
9380            read_error.to_string().contains("review-932"),
9381            "{read_error}"
9382        );
9383        let error = repo
9384            .refuse_new_ignored_files(repo.root(), &baseline)
9385            .unwrap_err();
9386
9387        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9388        assert!(error.to_string().contains("review-932"), "{error}");
9389    }
9390
9391    #[test]
9392    fn a_nested_repository_record_is_read_as_a_plain_path() {
9393        let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
9394        assert_eq!(Path::new("vendor/checkout"), path);
9395        assert!(nested);
9396
9397        let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
9398        assert_eq!(Path::new("vendor/notes.txt"), path);
9399        assert!(!nested);
9400
9401        assert!(untracked_record(b"/", "untracked").is_err());
9402    }
9403
9404    #[cfg(unix)]
9405    #[test]
9406    fn a_non_utf8_git_path_is_preserved_without_loss() {
9407        use std::os::unix::ffi::OsStrExt;
9408
9409        let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
9410
9411        assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
9412    }
9413
9414    #[test]
9415    fn guarded_merge_pins_the_reviewed_head() {
9416        let args = merge_pr_args("36", Some("abc123"), true);
9417        assert_eq!(
9418            vec![
9419                "pr",
9420                "merge",
9421                "36",
9422                "--squash",
9423                "--delete-branch",
9424                "--match-head-commit",
9425                "abc123"
9426            ],
9427            args
9428        );
9429    }
9430
9431    #[test]
9432    fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
9433        let pr = PrRef {
9434            number: 7,
9435            url: "https://example.test/pull/7".into(),
9436            title: "part one".into(),
9437        };
9438        let result = reconcile_pr_creation(
9439            "split-34-1",
9440            Err(crate::error::SparError::new("connection lost")),
9441            Ok(Some(pr)),
9442        )
9443        .unwrap();
9444        assert_eq!(7, result.number);
9445    }
9446
9447    #[test]
9448    fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
9449        let error = reconcile_pr_creation(
9450            "split-34-1",
9451            Err(crate::error::SparError::new("permission denied")),
9452            Ok(None),
9453        )
9454        .unwrap_err();
9455        assert!(error.to_string().contains("permission denied"), "{error}");
9456    }
9457
9458    #[test]
9459    fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
9460        let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
9461        assert!(pr_for_base(text, "split-34-2", "split-34-1")
9462            .unwrap()
9463            .is_none());
9464        let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
9465        assert_eq!(7, found.number);
9466    }
9467
9468    #[test]
9469    fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
9470        let result = reconcile_comment_post(
9471            34,
9472            "the summary",
9473            crate::error::SparError::new("connection lost"),
9474            Ok(vec![serde_json::json!({"body": "the summary"})]),
9475        );
9476        assert!(result.is_ok(), "{result:?}");
9477    }
9478
9479    #[test]
9480    fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
9481        let error = reconcile_comment_post(
9482            34,
9483            "the summary",
9484            crate::error::SparError::new("connection lost"),
9485            Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
9486        )
9487        .unwrap_err();
9488        assert_eq!("connection lost", error.to_string());
9489    }
9490
9491    #[test]
9492    fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
9493        let error = reconcile_comment_post(
9494            34,
9495            "the summary",
9496            crate::error::SparError::new("connection lost"),
9497            Err(crate::error::SparError::new("comments unavailable")),
9498        )
9499        .unwrap_err();
9500        assert!(
9501            error.to_string().contains("could not be verified"),
9502            "{error}"
9503        );
9504        assert!(
9505            error.to_string().contains("comments unavailable"),
9506            "{error}"
9507        );
9508        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9509        assert!(!error.worth_retrying());
9510    }
9511
9512    #[test]
9513    fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
9514        let result = reconcile_issue_edit(
9515            34,
9516            "wanted body",
9517            crate::error::SparError::new("connection lost"),
9518            Ok("wanted body".to_string()),
9519        );
9520        assert!(result.is_ok(), "{result:?}");
9521    }
9522
9523    #[test]
9524    fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
9525        let error = reconcile_issue_edit(
9526            34,
9527            "wanted body",
9528            crate::error::SparError::new("connection lost"),
9529            Err(crate::error::SparError::new("issue unavailable")),
9530        )
9531        .unwrap_err();
9532        assert!(
9533            error.to_string().contains("could not be verified"),
9534            "{error}"
9535        );
9536        assert!(error.to_string().contains("issue unavailable"), "{error}");
9537        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9538        assert!(!error.worth_retrying());
9539    }
9540
9541    #[test]
9542    fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
9543        let found = ExistingIssue {
9544            number: 101,
9545            url: "https://example.test/issues/101".into(),
9546            title: "child".into(),
9547            body: "body".into(),
9548            open: true,
9549        };
9550        let url = reconcile_issue_creation(
9551            "child",
9552            Err(crate::error::SparError::new("connection lost")),
9553            Ok(Some(found)),
9554        )
9555        .unwrap();
9556        assert_eq!("https://example.test/issues/101", url);
9557    }
9558
9559    #[test]
9560    fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
9561        let error = reconcile_issue_creation(
9562            "child",
9563            Err(crate::error::SparError::new("permission denied")),
9564            Ok(None),
9565        )
9566        .unwrap_err();
9567        assert!(error.to_string().contains("permission denied"), "{error}");
9568    }
9569
9570    #[test]
9571    fn an_unverifiable_issue_creation_is_marked_uncertain() {
9572        let error = reconcile_issue_creation(
9573            "child",
9574            Err(crate::error::SparError::new("connection lost")),
9575            Err(crate::error::SparError::new("issues unavailable")),
9576        )
9577        .unwrap_err();
9578        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9579        assert!(!error.worth_retrying());
9580    }
9581
9582    #[test]
9583    fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
9584        let result = reconcile_failed_split_push(
9585            "split-34-1",
9586            crate::error::SparError::new("connection lost"),
9587            Ok("abc123\n".into()),
9588            Ok("abc123\trefs/heads/split-34-1\n".into()),
9589        );
9590        assert!(result.is_ok(), "{result:?}");
9591    }
9592
9593    #[test]
9594    fn a_split_push_collision_is_definite_and_never_overwrites() {
9595        let error = reconcile_failed_split_push(
9596            "split-34-1",
9597            crate::error::SparError::new("lease rejected"),
9598            Ok("abc123\n".into()),
9599            Ok("def456\trefs/heads/split-34-1\n".into()),
9600        )
9601        .unwrap_err();
9602        assert!(!error.retain_worktree());
9603        assert!(
9604            error.to_string().contains("Nothing was overwritten"),
9605            "{error}"
9606        );
9607    }
9608
9609    #[test]
9610    fn an_unreadable_split_push_result_keeps_the_worktree() {
9611        let error = reconcile_failed_split_push(
9612            "split-34-1",
9613            crate::error::SparError::new("connection lost"),
9614            Ok("abc123\n".into()),
9615            Err(crate::error::SparError::new("origin unavailable")),
9616        )
9617        .unwrap_err();
9618        assert!(error.retain_worktree());
9619        assert!(error.to_string().contains("could not confirm"), "{error}");
9620    }
9621
9622    /// Follow-up deduplication compares a title it computed against the title
9623    /// GitHub stored. If those two transforms can disagree, the check never
9624    /// matches and every review round files another copy of the same issue.
9625    #[test]
9626    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
9627        let repo = repo_for_titles();
9628        for raw in [
9629            "Retry loop spins \u{2014} Retry-After parses to zero",
9630            "plain title",
9631            "  spread   over\nlines  ",
9632            "\u{1F916} Generated with something",
9633            &format!("a \u{2014} {}", "very long title ".repeat(20)),
9634            &"x".repeat(300),
9635            &format!("{} \u{2014} end", "y".repeat(88)),
9636            // Exactly the budget, with two spaceless dashes. The scrub turns
9637            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
9638            // character over budget per dash and a second pass clips again,
9639            // producing a different string. Scrub-then-clip cannot.
9640            &{
9641                let tail = "a\u{2014}b c\u{2014}d";
9642                let pad = Style::default().max_title_chars - tail.chars().count();
9643                format!("{}{tail}", "w".repeat(pad))
9644            },
9645        ] {
9646            let once = repo.clean_title(raw).unwrap();
9647            let twice = repo.clean_title(&once).unwrap();
9648            assert_eq!(once, twice, "not idempotent for {raw:?}");
9649            assert!(
9650                once.chars().count() <= repo.style.max_title_chars,
9651                "over budget: {once:?}"
9652            );
9653            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
9654        }
9655    }
9656
9657    #[test]
9658    fn a_title_with_an_em_dash_survives_as_readable_text() {
9659        let repo = repo_for_titles();
9660        assert_eq!(
9661            "Retry loop spins, Retry-After parses to zero",
9662            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
9663                .unwrap()
9664        );
9665    }
9666
9667    #[test]
9668    fn sh_quote_survives_a_quote() {
9669        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
9670    }
9671
9672    #[test]
9673    fn sh_quote_wraps_a_space() {
9674        assert_eq!(
9675            "'/Applications/My App/spar'",
9676            sh_quote("/Applications/My App/spar")
9677        );
9678    }
9679
9680    #[test]
9681    fn finished_states_are_recognised_case_insensitively() {
9682        assert!(is_finished("MERGED"));
9683        assert!(is_finished("closed"));
9684        assert!(!is_finished("OPEN"));
9685        assert!(!is_finished(""));
9686    }
9687
9688    fn state() -> PersistedState {
9689        PersistedState {
9690            version: 1,
9691            checkpoint: 0,
9692            round: 4,
9693            next_actor: "codex".into(),
9694            status: Status::Pending,
9695            pr_head: "abc123".into(),
9696            ledger: Ledger::new(),
9697            filed: vec![],
9698            open_findings: vec![Finding {
9699                severity: Severity::Blocking,
9700                title: "Unchecked error".into(),
9701                detail: "the failure is discarded".into(),
9702                file: "src/a.rs:12".into(),
9703                ..Finding::default()
9704            }],
9705            disputes: vec![Dispute {
9706                title: "Retry limit".into(),
9707                file: "src/net.rs".into(),
9708                reasoning: "the caller already bounds it".into(),
9709            }],
9710            noted: vec![Finding {
9711                severity: Severity::NonBlocking,
9712                title: "Timeout is fixed".into(),
9713                file: "src/config.rs".into(),
9714                ..Finding::default()
9715            }],
9716        }
9717    }
9718
9719    #[test]
9720    fn a_state_comment_round_trips() {
9721        let body = format!(
9722            "{STATE_MARKER}\n{}\n-->",
9723            serde_json::to_string(&state()).unwrap()
9724        );
9725        let back = parse_state_comment(&body).unwrap();
9726        assert_eq!(4, back.round);
9727        assert_eq!("codex", back.next_actor);
9728        assert_eq!("abc123", back.pr_head);
9729        assert_eq!("Unchecked error", back.open_findings[0].title);
9730        assert_eq!("src/net.rs", back.disputes[0].file);
9731        assert_eq!("Timeout is fixed", back.noted[0].title);
9732    }
9733
9734    #[test]
9735    fn old_state_without_new_lists_still_parses() {
9736        let body = format!(
9737            "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
9738             \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
9739        );
9740        let back = parse_state_comment(&body).expect("old state");
9741        assert!(back.open_findings.is_empty());
9742        assert!(back.disputes.is_empty());
9743        assert!(back.noted.is_empty());
9744        assert!(back.pr_head.is_empty());
9745        assert_eq!(0, back.checkpoint);
9746    }
9747
9748    #[test]
9749    fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
9750        let mut local = state();
9751        local.pr_head = "old".into();
9752        local.round = 9;
9753        let mut remote = state();
9754        remote.pr_head = "current".into();
9755        remote.round = 4;
9756
9757        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9758        assert_eq!("current", chosen.pr_head);
9759        assert_eq!(4, chosen.round);
9760    }
9761
9762    #[test]
9763    fn checkpoint_order_breaks_same_round_ties() {
9764        let mut local = state();
9765        local.pr_head = "current".into();
9766        local.round = 4;
9767        local.checkpoint = 8;
9768        let mut remote = local.clone();
9769        remote.checkpoint = 7;
9770        remote.open_findings.clear();
9771
9772        let chosen = choose_state_for_head(vec![local], "current").unwrap();
9773        assert_eq!(8, chosen.checkpoint);
9774
9775        let mut local = state();
9776        local.pr_head = "current".into();
9777        local.round = 4;
9778        local.checkpoint = 8;
9779        let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
9780        assert_eq!(8, chosen.checkpoint);
9781    }
9782
9783    #[test]
9784    fn legacy_same_round_tie_keeps_the_local_checkpoint() {
9785        let mut local = state();
9786        local.pr_head = "current".into();
9787        local.round = 4;
9788        local.open_findings.push(Finding {
9789            title: "local checkpoint".into(),
9790            ..Finding::default()
9791        });
9792        let mut remote = state();
9793        remote.pr_head = "current".into();
9794        remote.round = 4;
9795
9796        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9797        assert_eq!(
9798            "local checkpoint",
9799            chosen.open_findings.last().unwrap().title
9800        );
9801    }
9802
9803    /// It must render as nothing, so PRs are not littered with machine state.
9804    #[test]
9805    fn the_state_block_is_an_html_comment() {
9806        let body = format!(
9807            "{STATE_MARKER}\n{}\n-->",
9808            serde_json::to_string(&state()).unwrap()
9809        );
9810        assert!(body.starts_with("<!--"));
9811        assert!(body.trim_end().ends_with("-->"));
9812        assert!(!body[..body.find('{').unwrap()].contains("-->"));
9813    }
9814
9815    #[test]
9816    fn an_unrelated_json_block_is_not_state() {
9817        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
9818    }
9819
9820    /// Patching and deleting a comment is not something to do on a substring.
9821    ///
9822    /// A maintainer who notices the block and asks about it writes a comment
9823    /// containing `spar:state`, and the loose filter could not tell theirs from
9824    /// spar's. The next `persist` replaced their words with JSON and
9825    /// `spar clean --pr-state` later deleted it.
9826    #[test]
9827    fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
9828        let block = format!(
9829            "{STATE_MARKER}\n{}\n-->",
9830            serde_json::to_string(&state()).unwrap()
9831        );
9832        assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
9833        assert!(
9834            is_state_comment(&block, "Spar-Bot", "spar-bot"),
9835            "a login differs only in case"
9836        );
9837
9838        assert!(
9839            !is_state_comment(
9840                "why is there a `<!-- spar:state` block on this PR?",
9841                "maintainer",
9842                "spar-bot"
9843            ),
9844            "a question about the block was treated as the block"
9845        );
9846        assert!(
9847            !is_state_comment(&block, "maintainer", "spar-bot"),
9848            "somebody else's comment is not spar's to rewrite"
9849        );
9850        assert!(
9851            !is_state_comment(
9852                &format!("here is what spar writes:\n\n{block}"),
9853                "spar-bot",
9854                "spar-bot"
9855            ),
9856            "the marker has to open the body, not appear in it"
9857        );
9858        assert!(
9859            !is_state_comment(
9860                &format!("{STATE_MARKER}\n{{not json\n-->"),
9861                "spar-bot",
9862                "spar-bot"
9863            ),
9864            "a block that does not parse is not state"
9865        );
9866        assert!(
9867            !is_state_comment(&block, "spar-bot", ""),
9868            "an unknown viewer proves nothing about who wrote it"
9869        );
9870    }
9871
9872    #[test]
9873    fn a_malformed_state_comment_is_none_not_a_panic() {
9874        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
9875    }
9876
9877    #[test]
9878    fn atomic_write_leaves_no_temp_file() {
9879        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
9880        let _ = std::fs::remove_dir_all(&dir);
9881        let path = dir.join("state").join("pr-7.json");
9882        write_json_atomic(&path, &state()).unwrap();
9883        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
9884            .unwrap()
9885            .flatten()
9886            .filter_map(|e| e.file_name().to_str().map(str::to_string))
9887            .collect();
9888        assert_eq!(vec!["pr-7.json".to_string()], files);
9889        let _ = std::fs::remove_dir_all(&dir);
9890    }
9891
9892    #[test]
9893    fn atomic_write_overwrites_rather_than_accumulating() {
9894        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
9895        let _ = std::fs::remove_dir_all(&dir);
9896        let path = dir.join("pr-7.json");
9897        for round in 1..4 {
9898            let mut s = state();
9899            s.round = round;
9900            write_json_atomic(&path, &s).unwrap();
9901        }
9902        let back: PersistedState =
9903            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
9904        assert_eq!(3, back.round);
9905        let _ = std::fs::remove_dir_all(&dir);
9906    }
9907
9908    #[test]
9909    fn style_from_env_defaults_to_enforcing() {
9910        std::env::remove_var("SPAR_BAN_EM_DASH");
9911        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
9912        let style = style_from_env();
9913        assert!(style.ban_em_dash && style.ban_ai_attribution);
9914        assert!(
9915            !style.terse,
9916            "the commit filter must not truncate a commit message"
9917        );
9918    }
9919}
9920
9921#[cfg(test)]
9922mod comment_page_tests {
9923    use super::*;
9924
9925    #[test]
9926    fn a_single_merged_array_is_read() {
9927        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
9928        assert_eq!(2, pages.len());
9929        assert_eq!(Some(2), pages[1]["id"].as_i64());
9930    }
9931
9932    #[test]
9933    fn concatenated_pages_from_an_older_gh_are_read_too() {
9934        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
9935        assert_eq!(2, pages.len());
9936    }
9937
9938    /// A comment body containing a bracket pair used to split the payload into
9939    /// two invalid halves, so no state comment was found and a resume silently
9940    /// started from round one.
9941    #[test]
9942    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
9943        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
9944        let pages = parse_comment_pages(text);
9945        assert_eq!(2, pages.len(), "{pages:?}");
9946        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
9947    }
9948
9949    #[test]
9950    fn empty_output_is_no_comments_not_a_panic() {
9951        assert!(parse_comment_pages("").is_empty());
9952        assert!(parse_comment_pages("   ").is_empty());
9953        assert!(parse_comment_pages("[]").is_empty());
9954    }
9955
9956    #[test]
9957    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
9958        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
9959    }
9960
9961    #[test]
9962    fn a_write_postcheck_rejects_truncated_comment_pages() {
9963        let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
9964        assert!(
9965            error.to_string().contains("unexpected comment pages"),
9966            "{error}"
9967        );
9968    }
9969
9970    #[test]
9971    fn a_write_postcheck_rejects_empty_or_non_array_output() {
9972        assert!(try_parse_comment_pages("").is_err());
9973        assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
9974        assert!(try_parse_comment_pages("[]").is_ok());
9975    }
9976
9977    #[test]
9978    fn state_is_found_in_the_last_matching_comment() {
9979        let payload = |round: u32| {
9980            format!(
9981                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
9982            )
9983        };
9984        let text = serde_json::to_string(&serde_json::json!([
9985            {"id": 1, "body": payload(1)},
9986            {"id": 2, "body": "looks good to me"},
9987            {"id": 3, "body": payload(5)},
9988        ]))
9989        .unwrap();
9990        let pages = parse_comment_pages(&text);
9991        let last = pages
9992            .iter()
9993            .rev()
9994            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
9995            .unwrap();
9996        assert_eq!(5, last.round);
9997    }
9998}
9999
10000#[cfg(test)]
10001mod linked_pr_tests {
10002    use super::*;
10003
10004    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
10005    /// It carries an id and a whole repository object per linked issue, and a
10006    /// parser that chokes on those reports "no pull request", which is the one
10007    /// answer that makes spar implement over the top of somebody's work.
10008    const REAL_PAYLOAD: &str = r#"[
10009      {"number":14252,"title":"fix: reject leading-dash branch names",
10010       "url":"https://github.com/cli/cli/pull/14252",
10011       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
10012         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
10013         "url":"https://github.com/cli/cli/issues/14238"}]},
10014      {"number":14217,"title":"another change",
10015       "url":"https://github.com/cli/cli/pull/14217",
10016       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
10017         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
10018         "url":"https://github.com/cli/cli/issues/9761"}]},
10019      {"number":14200,"title":"unlinked work",
10020       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
10021    ]"#;
10022
10023    #[test]
10024    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
10025        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
10026        assert_eq!(14252, pr.number);
10027        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
10028    }
10029
10030    #[test]
10031    fn the_right_pr_is_picked_out_of_several() {
10032        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
10033    }
10034
10035    #[test]
10036    fn an_issue_nobody_is_working_on_finds_nothing() {
10037        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
10038    }
10039
10040    #[test]
10041    fn an_unlinked_pr_is_never_matched() {
10042        // 14200 closes nothing, so no issue number should ever return it.
10043        for issue in [14200, 0, 1] {
10044            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
10045                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
10046            }
10047        }
10048    }
10049
10050    #[test]
10051    fn empty_or_broken_output_is_none_rather_than_a_panic() {
10052        assert!(find_linked_pr("", 1).is_none());
10053        assert!(find_linked_pr("[]", 1).is_none());
10054        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
10055        assert!(find_linked_pr("[{\"number\":", 1).is_none());
10056    }
10057
10058    /// "gh could not answer" and "nobody is working on it" are different
10059    /// answers, and only one of them means implement from scratch.
10060    ///
10061    /// The parse used to fold both into `None`, so an authentication failure or
10062    /// a rate limit read as an empty list and spar opened a second pull request
10063    /// for an issue somebody already had work open on.
10064    #[test]
10065    fn an_unreadable_list_is_not_an_empty_list() {
10066        assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
10067        assert!(linked_pr_rows("[{\"number\":").is_none());
10068        assert!(linked_pr_rows("").is_none());
10069
10070        let empty = linked_pr_rows("[]").expect("an empty list is an answer");
10071        assert!(empty.is_empty());
10072        assert!(pick_linked_pr(&empty, 14238).is_none());
10073
10074        let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
10075        assert_eq!(3, listed.len());
10076        assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
10077    }
10078
10079    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
10080    #[test]
10081    fn pr_view_reads_the_cross_repository_flag() {
10082        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
10083                       "baseRefName":"main","state":"OPEN",
10084                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
10085        let pr: PrView = serde_json::from_str(json).unwrap();
10086        assert!(pr.is_cross_repository);
10087        assert!(pr.is_open());
10088
10089        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
10090        assert!(
10091            !serde_json::from_str::<PrView>(&same_repo)
10092                .unwrap()
10093                .is_cross_repository
10094        );
10095    }
10096}
10097
10098#[cfg(test)]
10099mod min_number_tests {
10100    /// The floor is applied before the cap, which is the order that matters.
10101    /// spar takes the *lowest* numbered open items, so a repository with a tail
10102    /// of old issues would otherwise spend its whole run in the tail: the cap
10103    /// would be filled by the oldest items and the floor would never be
10104    /// reached. Filtering first is what makes the setting do anything.
10105    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
10106        let mut numbers: Vec<i64> = open.to_vec();
10107        numbers.sort_unstable();
10108        if min_number > 0 {
10109            numbers.retain(|n| *n >= min_number);
10110        }
10111        numbers.truncate(limit);
10112        numbers
10113    }
10114
10115    #[test]
10116    fn the_floor_is_applied_before_the_cap_not_after() {
10117        let open = [12, 13, 14, 480, 481, 482];
10118        assert_eq!(vec![480, 481], pick(&open, 2, 480));
10119        // Capping first would have returned the two oldest and then filtered
10120        // them all away, leaving nothing.
10121        assert!(!pick(&open, 2, 480).is_empty());
10122    }
10123
10124    #[test]
10125    fn no_floor_keeps_the_old_behaviour() {
10126        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
10127    }
10128
10129    #[test]
10130    fn the_floor_is_inclusive() {
10131        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
10132    }
10133
10134    #[test]
10135    fn a_floor_above_everything_open_yields_nothing() {
10136        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
10137    }
10138}
10139
10140#[cfg(test)]
10141mod review_record_tests {
10142    use super::*;
10143    use serde_json::json;
10144
10145    #[test]
10146    fn prune_state_knows_which_files_belong_to_a_pull_request() {
10147        assert_eq!(Some(12), state_file_number("pr-12.json"));
10148        assert_eq!(Some(12), state_file_number("review-12.json"));
10149        assert_eq!(Some(12), state_file_number("checkin-12.json"));
10150        for shared in [
10151            "spend.json",
10152            "implementors.json",
10153            "contested.json",
10154            "listen.json",
10155            "pr-.json",
10156            "pr-12.json.tmp",
10157            "pr-x.json",
10158        ] {
10159            assert_eq!(None, state_file_number(shared), "{shared}");
10160        }
10161    }
10162
10163    #[test]
10164    fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
10165        let reviews = vec![
10166            json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
10167            json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
10168        ];
10169        assert!(has_viewer_review(&reviews, "me", "the body", "aaa"));
10170        assert!(!has_viewer_review(&reviews, "me", "other", "aaa"));
10171        assert!(!has_viewer_review(&reviews, "alice", "the body", "aaa"));
10172    }
10173
10174    /// A clean review says the same thing on every commit. Taking the approval
10175    /// of an older commit for this one left the new head unapproved while the
10176    /// record said it was.
10177    #[test]
10178    fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
10179        let older = vec![
10180            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
10181        ];
10182        assert!(!has_viewer_review(&older, "me", "the body", "bbb"));
10183        let dismissed = vec![
10184            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
10185        ];
10186        assert!(!has_viewer_review(&dismissed, "me", "the body", "bbb"));
10187    }
10188
10189    #[test]
10190    fn a_response_splits_into_status_headers_and_body() {
10191        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";
10192        let resp = parse_api_response(text).expect("a response");
10193        assert_eq!(200, resp.status);
10194        assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
10195        assert_eq!(
10196            Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
10197            resp.headers.get("last-modified")
10198        );
10199        assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
10200
10201        let not_modified =
10202            parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
10203        assert_eq!(304, not_modified.status);
10204        assert!(not_modified.body.is_empty());
10205
10206        assert_eq!(None, parse_api_response(""));
10207        assert_eq!(None, parse_api_response("gh: command not found\n"));
10208    }
10209
10210    #[test]
10211    fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
10212        let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
10213        let error = crate::error::SparError::call_failed("gh: boom");
10214        assert!(
10215            reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(landed)).is_ok()
10216        );
10217
10218        let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
10219        let err = reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(missing))
10220            .unwrap_err();
10221        assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
10222
10223        let unreadable = Err(crate::error::SparError::new("no network"));
10224        let err = reconcile_review_post(5, "the body", "me", "aaa", error, unreadable).unwrap_err();
10225        assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
10226        assert!(err.to_string().contains("could not be verified"), "{err}");
10227    }
10228}