Skip to main content

spar/
repo.rs

1//! git and gh. Every outbound string passes through the style and concision
2//! gates before it reaches GitHub.
3
4use std::collections::{BTreeMap, BTreeSet};
5use std::ffi::OsStr;
6use std::fs::OpenOptions;
7use std::io::{BufRead, Read, Write};
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::sync::{Mutex, OnceLock};
11
12use serde::Deserialize;
13use serde_json::Value;
14use sha1::Sha1;
15use sha2::{Digest, Sha256};
16
17use crate::config::{Config, Drafts, Followups, StateStore};
18use crate::error::Result;
19use crate::model::{
20    Contested, ContestedItem, Followup, Issue, IssueRef, ItemKind, LiveHead, PersistedState, PrRef,
21    PrRow, PrSweepRow, PrView, PR_SWEEP_FIELDS,
22};
23use crate::proc::{self, ExecOpts};
24use crate::style::{self, Style};
25use crate::textsim;
26use crate::{bail, log, 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/// Where a branch spar opened a pull request from is published.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum PushTarget {
177    Origin,
178    /// The authenticated user's fork, for a repository origin would not let
179    /// them push to.
180    Fork(Fork),
181}
182
183/// A fork of this repository that spar publishes branches to.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct Fork {
186    /// The account that owns it, which `gh pr create --head` needs in front of
187    /// the branch name.
188    pub owner: String,
189    /// `owner/name`, for saying where a branch went.
190    pub full_name: String,
191    /// The SSH or HTTPS URL, whichever origin uses, pushed to directly so the
192    /// checkout's remotes are left as they were.
193    pub url: String,
194}
195
196impl PushTarget {
197    /// What `gh pr create --head` takes for a branch published here.
198    pub fn head(&self, branch: &str) -> String {
199        match self {
200            PushTarget::Origin => branch.to_string(),
201            PushTarget::Fork(fork) => format!("{}:{branch}", fork.owner),
202        }
203    }
204
205    fn remote(&self) -> &str {
206        match self {
207            PushTarget::Origin => "origin",
208            PushTarget::Fork(fork) => &fork.url,
209        }
210    }
211
212    fn describe(&self, branch: &str) -> String {
213        match self {
214            PushTarget::Origin => format!("origin/{branch}"),
215            PushTarget::Fork(fork) => format!("{branch} on the fork {}", fork.full_name),
216        }
217    }
218}
219
220#[derive(Debug)]
221pub struct Repo {
222    root: PathBuf,
223    pub style: Style,
224    pub branch_prefix: String,
225    pub state_store: StateStore,
226    pub followups: Followups,
227    pub drafts: Drafts,
228    /// Publish a new branch from a fork when origin refuses the push.
229    pub fork_when_denied: bool,
230    /// The fork new branches go to once origin has refused one, found at most
231    /// once.
232    fork: OnceLock<Fork>,
233    /// The login `gh` is authenticated as, asked at most once.
234    ///
235    /// `OnceLock` rather than `OnceCell` because `&Repo` crosses a
236    /// `std::thread::scope` whenever both agents are asked at the same time,
237    /// and only `OnceLock` is `Sync`.
238    viewer: OnceLock<String>,
239    /// Highest persisted checkpoint observed for each pull request.
240    ///
241    /// Kept in memory so a transient state read cannot reset the sequence
242    /// after a resume already loaded a newer checkpoint.
243    checkpoints: Mutex<BTreeMap<i64, u64>>,
244    writes: WriteStats,
245}
246
247#[derive(Debug, Default)]
248struct WriteStats {
249    attempted: AtomicUsize,
250    failed: AtomicUsize,
251}
252
253#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
254pub(crate) struct WriteSummary {
255    pub(crate) attempted: usize,
256    pub(crate) failed: usize,
257}
258
259impl WriteSummary {
260    pub(crate) fn succeeded(self) -> usize {
261        self.attempted.saturating_sub(self.failed)
262    }
263}
264
265/// Ignored, untracked paths present before an editing call starts.
266///
267/// Existing build artifacts are deliberately part of the baseline. Callers can
268/// therefore distinguish them from an ignored file the editing call created
269/// and avoid deleting the latter as if no work had happened.
270#[derive(Debug, Clone)]
271pub(crate) struct WorktreeBaseline {
272    attributes: AttributeState,
273    ignored_untracked: IgnoredState,
274    git_state: GitState,
275}
276
277/// The recorded Git state of a worktree that must remain read only.
278///
279/// Read-only agent calls are still ordinary processes. Capturing the complete
280/// working state lets callers refuse to publish their answer or delete the
281/// checkout if a call writes despite its instructions.
282#[derive(Debug, Clone)]
283pub(crate) struct WorktreeCheckpoint {
284    path: PathBuf,
285    attributes: AttributeState,
286    git_state: GitState,
287    ignored_untracked: IgnoredState,
288}
289
290#[derive(Debug, Clone, Default, PartialEq, Eq)]
291pub(crate) struct AttributeState {
292    files: BTreeMap<PathBuf, [u8; 32]>,
293}
294
295/// Exact paths and fingerprints for every untracked file, plus ignored paths.
296///
297/// Paths stay as operating-system strings so a non-UTF-8 filename cannot be
298/// merged with another path by lossy command-output conversion.
299#[derive(Debug, Clone, Default, PartialEq, Eq)]
300pub(crate) struct IgnoredState {
301    files: BTreeMap<PathBuf, UntrackedFile>,
302    ignored: BTreeSet<PathBuf>,
303}
304
305/// A bounded-cost fingerprint for an untracked filesystem entry.
306///
307/// Content hashing every ignored compiler artifact made each checkpoint read
308/// gigabytes. File identity, type, size, timestamps, and mode detect ordinary
309/// writes without rereading build output. Unix change time also changes when a
310/// writer restores the modification time.
311#[derive(Debug, Clone, PartialEq, Eq)]
312struct UntrackedFile {
313    kind: u8,
314    len: u64,
315    modified: Option<std::time::SystemTime>,
316    created: Option<std::time::SystemTime>,
317    readonly: bool,
318    symlink_target: Option<Vec<u8>>,
319    #[cfg(unix)]
320    device: u64,
321    #[cfg(unix)]
322    inode: u64,
323    #[cfg(unix)]
324    mode: u32,
325    #[cfg(unix)]
326    change_seconds: i64,
327    #[cfg(unix)]
328    change_nanoseconds: i64,
329}
330
331#[derive(Debug, Clone, Default, PartialEq, Eq)]
332pub(crate) struct GitState {
333    repositories: BTreeMap<PathBuf, RepositoryState>,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq)]
337struct RepositoryState {
338    head: String,
339    unsafe_index_flags: Vec<u8>,
340    tracked: BTreeMap<PathBuf, TrackedEntry>,
341    gitlinks: BTreeMap<PathBuf, String>,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
345struct TrackedEntry {
346    index_mode: String,
347    index_oid: String,
348    worktree: Option<WorktreeFile>,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq)]
352struct WorktreeFile {
353    mode: String,
354    #[cfg(unix)]
355    permissions: u32,
356    raw_oid: String,
357    fingerprint: [u8; 32],
358    content: [u8; 32],
359}
360
361struct Gitlink {
362    path: PathBuf,
363    oid: String,
364}
365
366struct IndexEntry {
367    path: PathBuf,
368    mode: String,
369    oid: String,
370}
371
372impl IgnoredState {
373    fn is_ignored(&self, path: &Path) -> bool {
374        self.ignored.contains(path)
375    }
376
377    fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
378        let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
379        paths.extend(after.files.keys().cloned());
380        paths
381            .into_iter()
382            .filter(|path| {
383                self.files.get(path) != after.files.get(path)
384                    || self.is_ignored(path) != after.is_ignored(path)
385            })
386            .collect()
387    }
388
389    fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
390        self.files
391            .iter()
392            .filter(|(path, state)| after.files.get(*path) != Some(*state))
393            .map(|(path, _)| path.clone())
394            .collect()
395    }
396
397    fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
398        after
399            .files
400            .keys()
401            .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
402            .cloned()
403            .collect()
404    }
405
406    /// Whether anything moved here that is not recognized build or cache
407    /// output.
408    ///
409    /// A read-only call is held to its word by comparing the worktree before
410    /// and after. Verifying a finding usually means building the project and
411    /// running its tests, which rewrites that output, and holding a reviewer to
412    /// a byte-identical `dist/` threw away the review it had just done. What
413    /// the call was asked to judge is the tracked tree, and that is compared as
414    /// strictly as ever, along with every other untracked file.
415    pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
416        let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
417        paths.extend(after.files.keys());
418        paths.into_iter().any(|path| {
419            if self.files.get(path) == after.files.get(path)
420                && self.is_ignored(path) == after.is_ignored(path)
421            {
422                return false;
423            }
424            !self.generated_move(after, path)
425        })
426    }
427
428    /// What a read-only call left behind, split by whether it condemns the
429    /// answer.
430    ///
431    /// `changed_beyond_generated` answers this from a list of directory names,
432    /// which is a guess about where a build writes. The repository already
433    /// states where it writes, in its own ignore rules, and a review died over
434    /// the gap: a reviewer ran the project's binding generator to check a
435    /// finding, that wrote a lockfile beside its own manifest rather than under
436    /// `target/`, and both agents lost an hour of work to a path Git had been
437    /// told to disown.
438    ///
439    /// Forgiving only the creation of such a path was not enough, because the
440    /// call is not the only writer. A read-only call runs for minutes in the
441    /// primary checkout, which is a live desk: Finder rewrites `.DS_Store`, an
442    /// editor rewrites its own state, and a triage that asks two agents at once
443    /// compares both of them against that same directory. One ambient write
444    /// discarded two good answers and ended the run with nothing scheduled. So
445    /// a path the project ignores is out of scope however it moved, and what
446    /// remains fatal is what the call was actually asked about: the tracked
447    /// tree, and any ordinary untracked file, because a reviewer was told to
448    /// keep its scratch out of the worktree.
449    ///
450    /// Nothing is silent. A tolerated path that is not recognized build output
451    /// is named by the caller, so an agent that rewrote a person's `.env` is
452    /// reported rather than hidden. That is the trade this makes: the answer of
453    /// a call that never commits is worth more than the guarantee, and the
454    /// warning carries what the guarantee used to.
455    pub(crate) fn read_only_leavings(&self, after: &Self) -> ReadOnlyLeavings {
456        let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
457        paths.extend(after.files.keys());
458        let mut leavings = ReadOnlyLeavings::default();
459        for path in paths {
460            if self.files.get(path) == after.files.get(path)
461                && self.is_ignored(path) == after.is_ignored(path)
462            {
463                continue;
464            }
465            if !self.ignored_file_move(after, path) {
466                leavings.condemning.push(path.clone());
467            } else if !is_generated_artifact(path) {
468                leavings.tolerated.push(path.clone());
469            }
470        }
471        leavings
472    }
473
474    /// Whether what happened at `path` between these two states is a build or
475    /// cache directory doing its job, rather than a change a person would miss.
476    ///
477    /// Removal counts, on either side. A compiler that keeps an incremental
478    /// cache does not only add and rewrite: it finalizes a new session
479    /// directory and deletes the one it superseded, lock file and all. A rule
480    /// that forgave creation and rewriting but not removal therefore passed the
481    /// first build in a fresh worktree and failed the second one, which is the
482    /// build a reviewer runs to check a finding. Two of these rules disagreeing
483    /// about the same directory is what cost the run: the read-only check
484    /// forgave the deleted session and the commit-side check called it
485    /// unrepresentable, so the work was already on a pull request when the run
486    /// ended. There is one rule now, and this is it.
487    fn generated_move(&self, after: &Self, path: &Path) -> bool {
488        is_generated_artifact(path) && self.disposable_move(after, path)
489    }
490
491    /// Successful edits and read-only calls share an ignore policy. Spar's
492    /// resident worktrees belong to other runs, not to this call's output.
493    /// Cleanup separately preserves ignored data, including dependency clones.
494    fn ignored_file_move(&self, after: &Self, path: &Path) -> bool {
495        if path.starts_with(WORKTREE_DIR)
496            && self.files.get(path).is_some_and(|file| file.kind == 3)
497            && self.files.get(path) != after.files.get(path)
498        {
499            return false;
500        }
501        self.disposable_move(after, path)
502    }
503
504    /// Whether neither state has anything at `path` worth keeping, so whatever
505    /// happened there took nothing with it.
506    fn disposable_move(&self, after: &Self, path: &Path) -> bool {
507        self.disposable(path) && after.disposable(path)
508    }
509
510    /// Whether this state has nothing at `path` worth keeping: either the path
511    /// is not there at all, or it is there as an ignored file.
512    fn disposable(&self, path: &Path) -> bool {
513        !self.files.contains_key(path) || self.is_ignored(path)
514    }
515}
516
517impl GitState {
518    /// What moved between two states, said so a person can act on it.
519    ///
520    /// The guards that compare these used to report only that the worktree had
521    /// changed, which left the one question worth answering, which file, to a
522    /// `git status` run by hand against a tree that had moved on since.
523    pub(crate) fn describe_difference(&self, after: &Self) -> Option<String> {
524        let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
525        prefixes.extend(after.repositories.keys());
526        let mut notes: Vec<String> = Vec::new();
527        let mut paths: Vec<PathBuf> = Vec::new();
528        for prefix in prefixes {
529            let label = describe_repository(prefix);
530            match (
531                self.repositories.get(prefix),
532                after.repositories.get(prefix),
533            ) {
534                (None, Some(_)) => notes.push(format!("a repository appeared at {label}")),
535                (Some(_), None) => notes.push(format!("the repository at {label} is gone")),
536                (Some(before), Some(current)) => {
537                    if before.head != current.head {
538                        notes.push(format!("HEAD moved in {label}"));
539                    }
540                    if before.unsafe_index_flags != current.unsafe_index_flags {
541                        notes.push(format!("index flags changed in {label}"));
542                    }
543                    paths.extend(
544                        differing_keys(&before.gitlinks, &current.gitlinks)
545                            .chain(differing_keys(&before.tracked, &current.tracked))
546                            .map(|path| prefix.join(path)),
547                    );
548                }
549                (None, None) => {}
550            }
551        }
552        if !paths.is_empty() {
553            paths.sort();
554            paths.dedup();
555            notes.push(format!("tracked file(s) changed: {}", list_paths(&paths)));
556        }
557        (!notes.is_empty()).then(|| notes.join("; "))
558    }
559
560    /// The tracked half of what a read-only call left behind, with a checkout
561    /// that moved under the call reported rather than held against it.
562    pub(crate) fn read_only_difference(&self, after: &Self, cwd: &Path) -> Option<String> {
563        let difference = self.describe_difference(after)?;
564        let Some(moved) = self.moved_wholesale(after) else {
565            return Some(difference);
566        };
567        // Once, however many calls were reading when it happened: two triage
568        // agents share the checkout and would otherwise both report the move.
569        if said_once("moved", cwd, &moved) {
570            logwarn!(
571                "the checkout at {} moved while a read-only call was reading it: {moved}. The \
572                 answer was kept, and it describes the tree as it stood before the move.",
573                cwd.display()
574            );
575        }
576        None
577    }
578
579    /// Whether every difference between these two states is the checkout moving
580    /// as a whole rather than the call writing into it.
581    ///
582    /// The primary checkout is a live desk and a read-only call reads it for
583    /// minutes. A person or their editor pulls, the branch fast forwards, and
584    /// every file that arrived with it now differs from the snapshot taken
585    /// before the call. Holding the call to that snapshot threw away two good
586    /// triage answers and ended a run with nothing scheduled, over a change the
587    /// call had no part in.
588    ///
589    /// The index tells the two apart. A call that wrote leaves a path
590    /// disagreeing with the index, staged or unstaged, and leaves HEAD where it
591    /// was. A checkout that moved rewrote the index and the worktree together,
592    /// so every path it touched agrees with the index, and it moved HEAD to say
593    /// so. Anything else, a tracked file that changed while HEAD stood still
594    /// most of all, is the call's to answer for.
595    fn moved_wholesale(&self, after: &Self) -> Option<String> {
596        let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
597        prefixes.extend(after.repositories.keys());
598        let mut notes: Vec<String> = Vec::new();
599        for prefix in prefixes {
600            // A repository that appeared or vanished is not a checkout moving.
601            let before = self.repositories.get(prefix)?;
602            let current = after.repositories.get(prefix)?;
603            if before.unsafe_index_flags != current.unsafe_index_flags
604                || before.gitlinks != current.gitlinks
605            {
606                return None;
607            }
608            let moved: Vec<&PathBuf> = differing_keys(&before.tracked, &current.tracked).collect();
609            if before.head == current.head {
610                if moved.is_empty() {
611                    continue;
612                }
613                return None;
614            }
615            if !moved.iter().all(|path| {
616                checked_out(before.tracked.get(*path)) && checked_out(current.tracked.get(*path))
617            }) {
618                return None;
619            }
620            let label = describe_repository(prefix);
621            notes.push(match moved.len() {
622                0 => format!("HEAD moved in {label}"),
623                count => format!("HEAD moved in {label}, carrying {count} tracked file(s)"),
624            });
625        }
626        (!notes.is_empty()).then(|| notes.join("; "))
627    }
628}
629
630/// Whether a tracked path holds exactly what the index says it holds, so
631/// nothing there is staged or left unstaged.
632///
633/// A path the index no longer lists counts as checked out: only an index
634/// operation drops one, and a file left in the worktree after that is an
635/// ordinary untracked file, which the untracked half of the same check has.
636fn checked_out(entry: Option<&TrackedEntry>) -> bool {
637    entry.is_none_or(|entry| {
638        entry
639            .worktree
640            .as_ref()
641            .is_some_and(|file| file.raw_oid == entry.index_oid && file.mode == entry.index_mode)
642    })
643}
644
645/// Say what a call left among paths the project ignores, once per run for a
646/// given set of them.
647///
648/// The project's own ignore rules put these outside what the call was asked
649/// about, so this is a note about the desk the call ran on rather than a
650/// finding about the call. Said as a warning on every call, it became the
651/// loudest thing in the log of every run in a checkout with an editor open in
652/// it: `.DS_Store` twice per triage, and again for each review. It still names
653/// the paths, so an agent that rewrote a person's `.env` is reported rather
654/// than hidden.
655pub(crate) fn note_ignored_leavings(cwd: &Path, what: &str, paths: &[PathBuf]) {
656    if paths.is_empty() {
657        return;
658    }
659    let named = list_paths(paths);
660    if !said_once("ignored", cwd, &named) {
661        return;
662    }
663    logdim!(
664        "a read-only {what} left {} ignored file(s) changed in {}: {named}. It was kept; the \
665         project ignores these paths.",
666        paths.len(),
667        cwd.display()
668    );
669}
670
671/// Whether this run has yet to say this about this directory.
672///
673/// A run reads the same checkout dozens of times, and the ambient facts about
674/// it, an editor's own files moving and a branch advancing under the reader,
675/// are true for every one of those calls. Said once they are context; said
676/// each time they are the log.
677fn said_once(topic: &str, cwd: &Path, detail: &str) -> bool {
678    static SAID: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
679    SAID.lock()
680        .unwrap_or_else(std::sync::PoisonError::into_inner)
681        .insert(format!("{topic}\0{}\0{detail}", cwd.display()))
682}
683
684/// Name a repository inside a state for a report. The root carries an empty
685/// prefix, and calling that "" helps nobody.
686fn describe_repository(prefix: &Path) -> String {
687    if prefix.as_os_str().is_empty() {
688        "the worktree".to_string()
689    } else {
690        format!("the submodule at {:?}", prefix.as_os_str())
691    }
692}
693
694/// The keys whose values differ between two maps, present on either side.
695fn differing_keys<'a, V: PartialEq>(
696    before: &'a BTreeMap<PathBuf, V>,
697    after: &'a BTreeMap<PathBuf, V>,
698) -> impl Iterator<Item = &'a PathBuf> {
699    let mut keys: BTreeSet<&'a PathBuf> = before.keys().collect();
700    keys.extend(after.keys());
701    keys.into_iter()
702        .filter(move |key| before.get(*key) != after.get(*key))
703}
704
705/// What a read-only call left in the untracked and ignored tree.
706///
707/// Split rather than reduced to a yes or no, because the two halves are
708/// answered differently: one ends the call, the other is said out loud and the
709/// answer is kept.
710#[derive(Default)]
711pub(crate) struct ReadOnlyLeavings {
712    /// Disposable on both sides, so out of scope for a call that only read.
713    /// Recognized build output is left out: rebuilding is what a reviewer was
714    /// asked to do, and nothing reports it today.
715    pub(crate) tolerated: Vec<PathBuf>,
716    /// Everything else. The answer does not survive these.
717    pub(crate) condemning: Vec<PathBuf>,
718}
719
720/// Put two accounts of what moved into one sentence, keeping whichever of them
721/// there is. Both guards that report a changed worktree ask the tracked tree
722/// and the untracked tree separately, and either can be the only answer.
723pub(crate) fn join_reasons(first: Option<String>, second: Option<String>) -> Option<String> {
724    match (first, second) {
725        (None, None) => None,
726        (Some(one), None) | (None, Some(one)) => Some(one),
727        (Some(first), Some(second)) => Some(format!("{first}; {second}")),
728    }
729}
730
731/// Name some paths for a person, without printing a build directory at them.
732///
733/// Every report of a path that stopped or was let through a call goes through
734/// this, so an error and the warning beside it read alike.
735pub(crate) fn list_paths(paths: &[PathBuf]) -> String {
736    const SHOWN: usize = 5;
737    let mut listed = paths
738        .iter()
739        .take(SHOWN)
740        .map(|path| format!("{:?}", path.as_os_str()))
741        .collect::<Vec<_>>()
742        .join(", ");
743    if paths.len() > SHOWN {
744        listed.push_str(&format!(", and {} more", paths.len() - SHOWN));
745    }
746    listed
747}
748
749/// Ignored files one commit attempt let through, gathered for a single report.
750///
751/// The checks that allow it run more than once per attempt, before staging,
752/// after staging, and again after the commit, because the tree could have moved
753/// under any of them. Reporting from inside each check said the same thing
754/// about the same files two and three times over.
755#[derive(Default)]
756struct IgnoredArtifacts {
757    new_paths: BTreeSet<PathBuf>,
758    changed_paths: BTreeSet<PathBuf>,
759    removed_paths: BTreeSet<PathBuf>,
760}
761
762impl IgnoredArtifacts {
763    fn left(&mut self, allowed: AllowedArtifacts) {
764        self.new_paths.extend(allowed.present);
765        self.removed_paths.extend(allowed.removed);
766    }
767
768    fn changed(&mut self, allowed: AllowedArtifacts) {
769        self.changed_paths.extend(allowed.present);
770        self.removed_paths.extend(allowed.removed);
771    }
772
773    /// Report paths without claiming ignored files are safe to delete.
774    fn report(&self, cwd: &Path) {
775        let paths: BTreeSet<_> = self
776            .new_paths
777            .iter()
778            .chain(&self.changed_paths)
779            .chain(&self.removed_paths)
780            .cloned()
781            .collect();
782        if !paths.is_empty() {
783            logdim!(
784                "the editing call changed {} ignored file(s) in {}: {}. They are outside \
785                 the commit; cleanup checks them separately.",
786                paths.len(),
787                cwd.display(),
788                list_paths(&paths.into_iter().collect::<Vec<_>>())
789            );
790        }
791    }
792}
793
794/// Ignored files a check let through, split by whether each one
795/// is still on disk, so the report can say what happened rather than call a
796/// deleted incremental session a file that was left behind.
797#[derive(Default)]
798struct AllowedArtifacts {
799    present: Vec<PathBuf>,
800    removed: Vec<PathBuf>,
801}
802
803impl AllowedArtifacts {
804    fn split(paths: Vec<PathBuf>, after: &IgnoredState) -> Self {
805        let (present, removed) = paths
806            .into_iter()
807            .partition(|path| after.files.contains_key(path));
808        Self { present, removed }
809    }
810}
811
812/// Generated directories that test and build commands routinely create.
813///
814/// Files under these directories are never committed, and they do not keep a
815/// worktree that is otherwise finished, so running the requested tests neither
816/// stops the tracked change reaching review nor leaves a checkout behind.
817fn is_generated_artifact(path: &Path) -> bool {
818    const DIRECTORIES: &[&str] = &[
819        "target",
820        "dist",
821        "node_modules",
822        "__pycache__",
823        ".pytest_cache",
824        ".mypy_cache",
825        ".ruff_cache",
826        ".tox",
827        ".nox",
828        ".venv",
829        "venv",
830        ".gradle",
831        ".build",
832        "DerivedData",
833        ".next",
834        ".nuxt",
835        ".svelte-kit",
836        ".turbo",
837        "coverage",
838    ];
839    path.components().any(|component| {
840        let std::path::Component::Normal(name) = component else {
841            return false;
842        };
843        DIRECTORIES
844            .iter()
845            .any(|directory| name == OsStr::new(directory))
846    })
847}
848
849/// Files the desktop writes into any folder a person opens.
850///
851/// Finder drops `.DS_Store` into every directory it shows, so a finished
852/// worktree someone once looked at in Finder held nothing but these, and
853/// `spar clean` kept every one of them as if it were work.
854fn is_desktop_metadata(path: &Path) -> bool {
855    path.file_name() == Some(OsStr::new(".DS_Store"))
856}
857
858fn merge_pr_args<'a>(
859    number: &'a str,
860    expected_head: Option<&'a str>,
861    delete_branch: bool,
862) -> Vec<&'a str> {
863    let mut args = vec!["pr", "merge", number, "--squash"];
864    if delete_branch {
865        args.push("--delete-branch");
866    }
867    if let Some(expected_head) = expected_head {
868        args.extend(["--match-head-commit", expected_head]);
869    }
870    args
871}
872
873/// The line of a failed push where the host says this account may not write
874/// to the repository, as opposed to the branch having moved or the network
875/// failing.
876///
877/// GitHub words it the same over SSH (`ERROR: Permission to o/r.git denied to
878/// u.`) and HTTPS (`remote: Permission to ...`), and in both it comes before
879/// several lines of generic advice, so the last line is the least useful one:
880/// it used to be all spar said, and all it said was "and the repository
881/// exists."
882fn push_denial(message: &str) -> Option<&str> {
883    message
884        .lines()
885        .map(|line| {
886            let line = line.trim();
887            let line = line.strip_prefix("ERROR:").unwrap_or(line);
888            line.strip_prefix("remote:").unwrap_or(line).trim()
889        })
890        .find(|line| line.starts_with("Permission to ") && line.contains(" denied to "))
891}
892
893/// The line of a failed push that names the ref the remote turned down and
894/// why, such as `[rejected] HEAD -> issue-7 (stale info)`.
895fn push_rejection(message: &str) -> Option<&str> {
896    message
897        .lines()
898        .map(|line| line.trim().trim_start_matches('!').trim())
899        .find(|line| line.starts_with("[rejected]") || line.starts_with("[remote rejected]"))
900}
901
902fn push_error(
903    target: &PushTarget,
904    branch: &str,
905    e: &crate::error::SparError,
906) -> crate::error::SparError {
907    let message = e.to_string();
908    if let Some(denied) = push_denial(&message) {
909        return spar_err!("could not push to {}. {denied}", target.describe(branch));
910    }
911    spar_err!(
912        "could not push to {}. {}\nCheck push access and whether the branch moved under you.",
913        target.describe(branch),
914        push_rejection(&message).unwrap_or(e.last_line())
915    )
916}
917
918/// The fork's URL, reached the way origin is: origin's push URL with only its
919/// `owner/repo` path replaced.
920///
921/// Keeping everything but the path is what keeps an SSH host alias, which is
922/// how one machine pushes as two GitHub accounts, a port, such as SSH over
923/// 443 where 22 is blocked, and an scp-style address with no user in it.
924/// GitHub's own URLs for the fork would drop all three. None for anything
925/// that is not a URL or an scp-style address with an `owner/repo` path.
926fn fork_url(origin_url: &str, full_name: &str) -> Option<String> {
927    let origin = origin_url.trim();
928    let path_start = match origin.find("://") {
929        Some(scheme) => {
930            let host = scheme + 3;
931            host + origin[host..].find('/')? + 1
932        }
933        None => {
934            // scp-style is `[user@]host:path`, and git reads a colon after a
935            // slash as part of a local path instead.
936            let colon = origin.find(':')?;
937            if origin[..colon].contains('/') {
938                return None;
939            }
940            colon + 1
941        }
942    };
943    let path = origin[path_start..].trim_end_matches('/');
944    let (path, suffix) = match path.strip_suffix(".git") {
945        Some(bare) => (bare, ".git"),
946        None => (path, ""),
947    };
948    let segments: Vec<&str> = path.split('/').collect();
949    if segments.len() < 2 || segments[segments.len() - 2..].iter().any(|s| s.is_empty()) {
950        return None;
951    }
952    let prefix = &segments[..segments.len() - 2];
953    let prefix = if prefix.is_empty() {
954        String::new()
955    } else {
956        format!("{}/", prefix.join("/"))
957    };
958    Some(format!(
959        "{}{prefix}{full_name}{suffix}",
960        &origin[..path_start]
961    ))
962}
963
964/// A fork from what GitHub said about it, reached the way origin is.
965///
966/// GitHub's own URL is the fallback for an origin `fork_url` cannot read, the
967/// SSH one when origin looks like SSH.
968fn fork_reached_like_origin(
969    owner: String,
970    full_name: String,
971    origin_url: &str,
972    ssh_url: String,
973    https_url: String,
974) -> Fork {
975    let origin = origin_url.trim();
976    let url = fork_url(origin, &full_name).unwrap_or_else(|| {
977        if origin.starts_with("git@") || origin.starts_with("ssh://") {
978            ssh_url
979        } else {
980            https_url
981        }
982    });
983    Fork {
984        owner,
985        full_name,
986        url,
987    }
988}
989
990#[derive(Deserialize)]
991struct ForkOwner {
992    login: String,
993}
994
995/// The fork GitHub made, or already had, in answer to a request to fork.
996fn fork_from_api(answer: &str, origin_url: &str) -> Result<Fork> {
997    #[derive(Deserialize)]
998    struct Answer {
999        full_name: String,
1000        owner: ForkOwner,
1001        ssh_url: String,
1002        clone_url: String,
1003    }
1004
1005    let answer: Answer = serde_json::from_str(answer.trim())
1006        .map_err(|e| spar_err!("unexpected answer from GitHub when forking: {e}"))?;
1007    Ok(fork_reached_like_origin(
1008        answer.owner.login,
1009        answer.full_name,
1010        origin_url,
1011        answer.ssh_url,
1012        answer.clone_url,
1013    ))
1014}
1015
1016/// The authenticated user's fork from a listing of this repository's forks
1017/// that only the user owns, or None when there is no such fork.
1018fn fork_from_listing(answer: &str, origin_url: &str) -> Result<Option<Fork>> {
1019    #[derive(Deserialize)]
1020    #[serde(rename_all = "camelCase")]
1021    struct Node {
1022        name_with_owner: String,
1023        owner: ForkOwner,
1024        ssh_url: String,
1025        url: String,
1026    }
1027
1028    let nodes: Vec<Node> = serde_json::from_str(answer.trim())
1029        .map_err(|e| spar_err!("unexpected answer from GitHub listing forks: {e}"))?;
1030    Ok(nodes.into_iter().next().map(|node| {
1031        fork_reached_like_origin(
1032            node.owner.login,
1033            node.name_with_owner,
1034            origin_url,
1035            node.ssh_url,
1036            node.url,
1037        )
1038    }))
1039}
1040
1041/// Where spar can push a pull request from a fork: the fork, when it is the
1042/// authenticated user's own, and nowhere when it is anybody else's.
1043fn own_fork_target(pr: &PrView, viewer: &str, origin_url: &str) -> Option<PushTarget> {
1044    let owner = pr.head_repository_owner.as_ref()?.login.trim();
1045    let name = pr.head_repository.as_ref()?.name.trim();
1046    if owner.is_empty() || name.is_empty() || !owner.eq_ignore_ascii_case(viewer.trim()) {
1047        return None;
1048    }
1049    let full_name = format!("{owner}/{name}");
1050    Some(PushTarget::Fork(Fork {
1051        owner: owner.to_string(),
1052        url: fork_url(origin_url, &full_name)?,
1053        full_name,
1054    }))
1055}
1056
1057fn reconcile_pr_creation(
1058    branch: &str,
1059    created: Result<String>,
1060    found: Result<Option<PrRef>>,
1061) -> Result<PrRef> {
1062    match (created, found) {
1063        (_, Ok(Some(pr))) => Ok(pr),
1064        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1065            "PR creation reported success but none was found for {branch}"
1066        ))),
1067        (Err(create), Ok(None)) => Err(spar_err!(
1068            "could not open a PR for {branch}. {}",
1069            create.last_line()
1070        )),
1071        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1072            "PR creation reported success for {branch}, but it could not be verified. {}",
1073            check.last_line()
1074        ))),
1075        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1076            "could not open a PR for {branch}. {} The result could not be verified: {}",
1077            create.last_line(),
1078            check.last_line()
1079        ))),
1080    }
1081}
1082
1083fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
1084    #[derive(Deserialize)]
1085    #[serde(rename_all = "camelCase")]
1086    struct Row {
1087        number: i64,
1088        #[serde(default)]
1089        url: String,
1090        #[serde(default)]
1091        title: String,
1092        base_ref_name: String,
1093    }
1094
1095    let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
1096        spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
1097    })?;
1098    Ok(rows
1099        .into_iter()
1100        .find(|row| row.base_ref_name == base)
1101        .map(|row| PrRef {
1102            number: row.number,
1103            url: row.url,
1104            title: row.title,
1105        }))
1106}
1107
1108/// Sign a comment spar composed, so it can be told from one the same account
1109/// wrote by hand. Idempotent: a body already signed is returned as it is.
1110fn signed(body: &str) -> String {
1111    if body.contains(COMMENT_MARKER) {
1112        return body.to_string();
1113    }
1114    format!("{body}\n\n{COMMENT_MARKER}")
1115}
1116
1117/// Whether a comment body is one spar composed rather than one a person wrote.
1118pub fn is_spar_comment(body: &str) -> bool {
1119    body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
1120}
1121
1122/// What `gh api -i` printed, taken apart.
1123#[derive(Debug, Clone, Default, PartialEq, Eq)]
1124pub struct ApiResponse {
1125    pub status: u16,
1126    /// Header names lowercased, values trimmed.
1127    pub headers: BTreeMap<String, String>,
1128    pub body: String,
1129}
1130
1131/// Split `gh api -i` output into the status, the headers, and the body. None
1132/// when it does not start with a status line, which is what a spawn that
1133/// printed an error looks like.
1134pub fn parse_api_response(stdout: &str) -> Option<ApiResponse> {
1135    let mut lines = stdout.split_inclusive('\n');
1136    let first = lines.next()?.trim_end_matches(['\r', '\n']);
1137    if !first.starts_with("HTTP/") {
1138        return None;
1139    }
1140    let status: u16 = first.split_whitespace().nth(1)?.parse().ok()?;
1141    let mut headers = BTreeMap::new();
1142    let mut body = String::new();
1143    let mut in_body = false;
1144    for line in lines {
1145        if in_body {
1146            body.push_str(line);
1147            continue;
1148        }
1149        let bare = line.trim_end_matches(['\r', '\n']);
1150        if bare.is_empty() {
1151            in_body = true;
1152            continue;
1153        }
1154        if let Some((name, value)) = bare.split_once(':') {
1155            headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
1156        }
1157    }
1158    Some(ApiResponse {
1159        status,
1160        headers,
1161        body,
1162    })
1163}
1164
1165/// The review requests on a pull request's timeline. The last hundred are
1166/// plenty: only the newest one that asks the viewer matters.
1167const REQUESTS_QUERY: &str = "\
1168query($owner: String!, $repo: String!, $number: Int!) {
1169  repository(owner: $owner, name: $repo) {
1170    pullRequest(number: $number) {
1171      timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], last: 100) {
1172        nodes {
1173          ... on ReviewRequestedEvent {
1174            createdAt
1175            requestedReviewer {
1176              ... on User { login }
1177              ... on Team { combinedSlug }
1178            }
1179          }
1180        }
1181      }
1182    }
1183  }
1184}";
1185
1186/// The requests `REQUESTS_QUERY` returned, oldest first. None when the answer
1187/// is not that shape. A request for somebody GitHub no longer knows names
1188/// nobody and is left out, or it would read as a team nobody could check.
1189pub fn parse_review_requests(text: &str) -> Option<Vec<crate::model::ReviewRequested>> {
1190    let v: Value = serde_json::from_str(text).ok()?;
1191    let nodes = v
1192        .pointer("/data/repository/pullRequest/timelineItems/nodes")?
1193        .as_array()?;
1194    let text_at = |node: &Value, path: &str| {
1195        node.pointer(path)
1196            .and_then(Value::as_str)
1197            .unwrap_or_default()
1198            .to_string()
1199    };
1200    Some(
1201        nodes
1202            .iter()
1203            .map(|node| crate::model::ReviewRequested {
1204                at: text_at(node, "/createdAt"),
1205                who: crate::model::Login {
1206                    login: text_at(node, "/requestedReviewer/login"),
1207                    slug: text_at(node, "/requestedReviewer/combinedSlug"),
1208                },
1209            })
1210            .filter(|r| !r.at.is_empty() && !(r.who.login.is_empty() && r.who.slug.is_empty()))
1211            .collect(),
1212    )
1213}
1214
1215/// The pull request a per item state file is about: `pr-12.json`,
1216/// `review-12.json`, `checkin-12.json`. None for anything else, so the shared
1217/// files beside them, `spend.json` and the rest, are never touched.
1218pub(crate) fn state_file_number(name: &str) -> Option<i64> {
1219    let stem = name.strip_suffix(".json")?;
1220    ["pr-", "review-", "checkin-"]
1221        .iter()
1222        .find_map(|prefix| stem.strip_prefix(prefix))
1223        .filter(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
1224        .and_then(|digits| digits.parse().ok())
1225}
1226
1227/// Whether the viewer has already submitted this review: exactly this body, on
1228/// this commit, and still standing. A clean review reads the same on every
1229/// commit, so a body alone would take an approval of an older commit for this
1230/// one; and a dismissed review was withdrawn, so it is not this one either.
1231fn has_viewer_review(reviews: &[Value], viewer: &str, body: &str, commit: &str) -> bool {
1232    reviews.iter().any(|review| {
1233        let field = |name: &str| review.get(name).and_then(Value::as_str).unwrap_or("");
1234        let standing = !matches!(
1235            field("state").to_uppercase().as_str(),
1236            "DISMISSED" | "PENDING"
1237        );
1238        review
1239            .get("user")
1240            .and_then(|u| u.get("login"))
1241            .and_then(Value::as_str)
1242            .is_some_and(|login| crate::comments::same_login(login, viewer))
1243            && field("body") == body
1244            && field("commit_id").eq_ignore_ascii_case(commit)
1245            && standing
1246    })
1247}
1248
1249fn reconcile_review_post(
1250    number: i64,
1251    body: &str,
1252    viewer: &str,
1253    commit: &str,
1254    post_error: crate::error::SparError,
1255    reviews: Result<Vec<Value>>,
1256) -> Result<()> {
1257    match reviews {
1258        Ok(reviews) if has_viewer_review(&reviews, viewer, body, commit) => Ok(()),
1259        Ok(_) => Err(post_error),
1260        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1261            "could not submit a review on #{number}. {} The result could not be verified: {}",
1262            post_error.last_line(),
1263            read_error.last_line()
1264        ))),
1265    }
1266}
1267
1268fn has_exact_comment(comments: &[Value], body: &str) -> bool {
1269    comments.iter().any(|comment| {
1270        comment
1271            .get("body")
1272            .and_then(Value::as_str)
1273            .is_some_and(|seen| seen == body)
1274    })
1275}
1276
1277fn reconcile_comment_post(
1278    number: i64,
1279    body: &str,
1280    post_error: crate::error::SparError,
1281    comments: Result<Vec<Value>>,
1282) -> Result<()> {
1283    match comments {
1284        Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
1285        Ok(_) => Err(post_error),
1286        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1287            "could not comment on #{number}. {} The result could not be verified: {}",
1288            post_error.last_line(),
1289            read_error.last_line()
1290        ))),
1291    }
1292}
1293
1294fn reconcile_issue_edit(
1295    number: i64,
1296    wanted: &str,
1297    edit_error: crate::error::SparError,
1298    observed: Result<String>,
1299) -> Result<()> {
1300    match observed {
1301        Ok(body) if body == wanted => Ok(()),
1302        Ok(_) => Err(spar_err!(
1303            "could not rewrite the body of #{number}. {}",
1304            edit_error.last_line()
1305        )),
1306        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1307            "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1308            edit_error.last_line(),
1309            read_error.last_line()
1310        ))),
1311    }
1312}
1313
1314fn issue_url_has_number(url: &str) -> bool {
1315    url.trim()
1316        .rsplit('/')
1317        .next()
1318        .and_then(|tail| tail.parse::<i64>().ok())
1319        .is_some_and(|number| number > 0)
1320}
1321
1322fn reconcile_issue_creation(
1323    title: &str,
1324    created: Result<String>,
1325    found: Result<Option<ExistingIssue>>,
1326) -> Result<String> {
1327    match (created, found) {
1328        (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1329        (_, Ok(Some(issue))) => Ok(issue.url),
1330        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1331            "issue creation reported success but no matching issue was found for {title:?}"
1332        ))),
1333        (Err(create), Ok(None)) => Err(spar_err!(
1334            "could not file issue {title:?}. {}",
1335            create.last_line()
1336        )),
1337        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1338            "issue creation reported success for {title:?}, but it could not be verified. {}",
1339            check.last_line()
1340        ))),
1341        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1342            "could not file issue {title:?}. {} The result could not be verified: {}",
1343            create.last_line(),
1344            check.last_line()
1345        ))),
1346    }
1347}
1348
1349fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1350    if output.trim().is_empty() {
1351        return Ok(None);
1352    }
1353    for line in output.lines() {
1354        let mut fields = line.split_whitespace();
1355        let oid = fields.next().unwrap_or_default();
1356        let name = fields.next().unwrap_or_default();
1357        if name == remote_ref && !oid.is_empty() {
1358            return Ok(Some(oid.to_string()));
1359        }
1360    }
1361    Err(spar_err!(
1362        "origin returned an unexpected ref listing for {remote_ref}"
1363    ))
1364}
1365
1366fn reconcile_failed_split_push(
1367    branch: &str,
1368    push_error: crate::error::SparError,
1369    local: Result<String>,
1370    remote: Result<String>,
1371) -> std::result::Result<(), SplitPushError> {
1372    let remote_ref = format!("refs/heads/{branch}");
1373    match (local, remote) {
1374        (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1375            Ok(Some(oid)) if oid == local.trim() => Ok(()),
1376            Ok(_) => Err(SplitPushError::new(
1377                format!(
1378                    "could not create origin/{branch}. {} The remote branch is absent or points \
1379                     somewhere else. Nothing was overwritten.",
1380                    push_error.last_line()
1381                ),
1382                false,
1383            )),
1384            Err(check) => Err(SplitPushError::new(
1385                format!(
1386                    "could not confirm whether origin/{branch} was created. {} The remote result \
1387                     could not be verified: {}",
1388                    push_error.last_line(),
1389                    check.last_line()
1390                ),
1391                true,
1392            )),
1393        },
1394        (local, remote) => {
1395            let check = match (local, remote) {
1396                (Err(local), Err(remote)) => format!(
1397                    "the local commit could not be read: {}; origin could not be read: {}",
1398                    local.last_line(),
1399                    remote.last_line()
1400                ),
1401                (Err(local), _) => {
1402                    format!("the local commit could not be read: {}", local.last_line())
1403                }
1404                (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1405                _ => unreachable!(),
1406            };
1407            Err(SplitPushError::new(
1408                format!(
1409                    "could not confirm whether origin/{branch} was created. {} The result could \
1410                     not be verified because {check}",
1411                    push_error.last_line()
1412                ),
1413                true,
1414            ))
1415        }
1416    }
1417}
1418
1419impl Repo {
1420    pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1421        let root =
1422            std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1423        // A linked worktree has a `.git` file rather than a directory, and a
1424        // bare-ish layout can have neither, so ask git instead of guessing.
1425        let inside = proc::run_str(
1426            &["git", "rev-parse", "--is-inside-work-tree"],
1427            &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1428        )
1429        .unwrap_or_default();
1430        if inside.trim() != "true" {
1431            bail!("not a git repository: {}", root.display());
1432        }
1433        let repo = Self {
1434            root,
1435            style: cfg.style.clone(),
1436            branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1437            state_store: cfg.loop_cfg.state_store,
1438            followups: cfg.loop_cfg.followups,
1439            drafts: cfg.loop_cfg.drafts,
1440            fork_when_denied: cfg.loop_cfg.fork_when_denied,
1441            fork: OnceLock::new(),
1442            viewer: OnceLock::new(),
1443            checkpoints: Mutex::new(BTreeMap::new()),
1444            writes: WriteStats::default(),
1445        };
1446        repo.self_exclude();
1447        Ok(repo)
1448    }
1449
1450    /// Keep spar's own scratch directories out of the target repo's
1451    /// `git status`.
1452    ///
1453    /// Written to `.git/info/exclude`, never to a tracked `.gitignore`: this is
1454    /// somebody else's repository and spar has no business committing to it.
1455    /// Best effort and silent on failure, because a read-only git directory is
1456    /// not a reason to abandon a run.
1457    fn self_exclude(&self) {
1458        let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1459        let git_dir = git_dir.trim();
1460        if git_dir.is_empty() {
1461            return;
1462        }
1463        let path = Path::new(git_dir).join("info").join("exclude");
1464        let existing = std::fs::read_to_string(&path).unwrap_or_default();
1465
1466        // The marker too. It is written into whichever directory the call ran
1467        // in, which for a shared checkout is the repository root, and an
1468        // untracked file there is one `git add -A` away from being committed.
1469        // The error names its path, which is how a person is meant to find it.
1470        let wanted = [
1471            format!("/{WORKTREE_DIR}/"),
1472            format!("/{STATE_DIR}/"),
1473            format!("{RECOVERY_MARKER_PREFIX}*"),
1474        ];
1475        let missing: Vec<&String> = wanted
1476            .iter()
1477            .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1478            .collect();
1479        if missing.is_empty() {
1480            return;
1481        }
1482
1483        use std::io::Write;
1484        if let Some(parent) = path.parent() {
1485            let _ = std::fs::create_dir_all(parent);
1486        }
1487        let mut block = String::new();
1488        if !existing.is_empty() && !existing.ends_with('\n') {
1489            block.push('\n');
1490        }
1491        block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1492        for line in missing {
1493            block.push_str(line);
1494            block.push('\n');
1495        }
1496        if let Ok(mut file) = std::fs::OpenOptions::new()
1497            .create(true)
1498            .append(true)
1499            .open(&path)
1500        {
1501            let _ = file.write_all(block.as_bytes());
1502        }
1503    }
1504
1505    pub fn root(&self) -> &Path {
1506        &self.root
1507    }
1508
1509    pub(crate) fn write_summary(&self) -> WriteSummary {
1510        WriteSummary {
1511            attempted: self.writes.attempted.load(Ordering::Relaxed),
1512            failed: self.writes.failed.load(Ordering::Relaxed),
1513        }
1514    }
1515
1516    pub(crate) fn record_write<T, E>(
1517        &self,
1518        result: std::result::Result<T, E>,
1519    ) -> std::result::Result<T, E> {
1520        self.record_write_outcome(result.is_err());
1521        result
1522    }
1523
1524    pub(crate) fn record_failed_write<T, E>(
1525        &self,
1526        result: std::result::Result<T, E>,
1527    ) -> std::result::Result<T, E> {
1528        if result.is_err() {
1529            self.record_write_outcome(true);
1530        }
1531        result
1532    }
1533
1534    fn record_write_outcome(&self, failed: bool) {
1535        self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1536        if failed {
1537            self.writes.failed.fetch_add(1, Ordering::Relaxed);
1538        }
1539    }
1540
1541    // -- gates ------------------------------------------------------------
1542
1543    /// Scrub, then verify. A leak here reaches GitHub, so it is a hard error
1544    /// rather than a warning: silent partial compliance is how a style rule
1545    /// erodes over a long run.
1546    pub fn clean(&self, text: &str) -> Result<String> {
1547        let out = style::scrub(text, &self.style);
1548        let bad = style::violations(&out, &self.style);
1549        if !bad.is_empty() {
1550            bail!(
1551                "style gate could not clean text ({}): {}",
1552                bad.join(", "),
1553                style::clip(&out, 300)
1554            );
1555        }
1556        Ok(out)
1557    }
1558
1559    /// Clean, and hold to a length budget. For anything a model wrote.
1560    pub fn clean_body(&self, text: &str) -> Result<String> {
1561        self.clean(&style::body(text, &self.style))
1562    }
1563
1564    /// The same, with an issue's far larger budget and its exemption for code.
1565    pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1566        self.clean(&style::issue_body(text, &self.style))
1567    }
1568
1569    /// The single transform every outbound title goes through.
1570    ///
1571    /// Scrub first, clip second, and never the other way round. Clipping first
1572    /// lets the scrub lengthen the result past the budget (an em dash becomes
1573    /// two characters), so a second pass would clip again and produce a
1574    /// different string. That broke follow-up deduplication silently: the
1575    /// lookup searched for one title while GitHub had stored another, no match
1576    /// was ever found, and a fresh duplicate issue was filed every review
1577    /// round. Doing it in this order makes the transform idempotent, which the
1578    /// tests assert.
1579    pub fn clean_title(&self, text: &str) -> Result<String> {
1580        Ok(style::title(&self.clean(text)?, &self.style))
1581    }
1582
1583    pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1584        let title = self.record_failed_write(self.clean_title(text))?;
1585        if title.trim().is_empty() {
1586            return self.record_failed_write(Err(spar_err!(
1587                "nothing left of the title after cleaning it"
1588            )));
1589        }
1590        Ok(title)
1591    }
1592
1593    pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1594        if self.followups == Followups::Issues {
1595            self.clean_nonempty_title_for_write(text)
1596        } else {
1597            self.clean_title(text)
1598        }
1599    }
1600
1601    // -- git --------------------------------------------------------------
1602
1603    fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1604        ExecOpts::new()
1605            .cwd(cwd.unwrap_or(&self.root))
1606            .check(check)
1607            .timeout_secs(600)
1608    }
1609
1610    pub fn git(&self, args: &[&str]) -> Result<String> {
1611        self.git_at(None, args)
1612    }
1613
1614    pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1615        let argv = git_without_maintenance_argv(args);
1616        proc::run(&argv, &self.git_opts(cwd, true))
1617    }
1618
1619    /// Run a parent-side Git operation without inherited background helpers.
1620    ///
1621    /// Editing calls are untrusted. Once one returns, status, staging, and
1622    /// committing happen in this process, so an inherited fsmonitor or automatic
1623    /// maintenance command must not become a way to execute outside its sandbox.
1624    fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1625        let argv = git_without_automation_argv(args);
1626        proc::run(
1627            &argv,
1628            &self.git_opts(Some(cwd), true).stop_descendants(true),
1629        )
1630    }
1631
1632    fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1633        let argv = git_without_automation_argv(args);
1634        proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1635            .map(|output| output.ok())
1636    }
1637
1638    /// Run git, tolerating failure. Returns whatever landed on stdout.
1639    pub fn git_try(&self, args: &[&str]) -> String {
1640        self.git_try_at(None, args)
1641    }
1642
1643    pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1644        let argv = git_without_maintenance_argv(args);
1645        proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1646    }
1647
1648    /// The base branch the remote actually points at, rather than assuming
1649    /// `main`. Falls back to the configured value when there is no origin.
1650    pub fn default_branch(&self, configured: &str) -> String {
1651        let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1652        match refname.trim().rsplit('/').next() {
1653            Some(name) if !name.is_empty() => name.to_string(),
1654            _ => configured.to_string(),
1655        }
1656    }
1657
1658    // -- branch naming and ownership --------------------------------------
1659    //
1660    // Branch names default to `issue-N`, which is exactly what a person would
1661    // name a branch by hand. Ownership therefore cannot be inferred from the
1662    // name, so every branch spar creates is recorded and cleanup only ever
1663    // touches what is in that record.
1664
1665    pub fn branch_for_issue(&self, issue: i64) -> String {
1666        format!("{}issue-{issue}", self.branch_prefix)
1667    }
1668
1669    pub fn branch_for_pr(&self, number: i64) -> String {
1670        format!("{}pr-{number}", self.branch_prefix)
1671    }
1672
1673    /// One part of a split, numbered from 1 within its parent.
1674    ///
1675    /// Its own namespace rather than `issue-N`, because the parts of a split
1676    /// pull request have no issue of their own and would otherwise collide with
1677    /// the branch of the issue that happens to share the parent's number.
1678    ///
1679    /// The name a part is tried on first. `worktree_for_split` may end up on a
1680    /// suffixed one, because this name is not free forever.
1681    pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1682        format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1683    }
1684
1685    fn ledger_path(&self) -> PathBuf {
1686        self.root.join(STATE_DIR).join("branches.json")
1687    }
1688
1689    pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1690        std::fs::read_to_string(self.ledger_path())
1691            .ok()
1692            .and_then(|text| serde_json::from_str(&text).ok())
1693            .unwrap_or_default()
1694    }
1695
1696    pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1697        let mut data = self.known_branches();
1698        data.insert(
1699            branch.to_string(),
1700            BranchRecord {
1701                kind: kind.to_string(),
1702                number,
1703            },
1704        );
1705        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1706            logdim!("could not record branch {branch}: {e}");
1707        }
1708    }
1709
1710    pub fn forget_branch(&self, branch: &str) {
1711        let mut data = self.known_branches();
1712        if data.remove(branch).is_none() {
1713            return;
1714        }
1715        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1716            logdim!("could not update the branch record: {e}");
1717        }
1718    }
1719
1720    // -- worktrees --------------------------------------------------------
1721
1722    fn worktree_path(&self, name: &str) -> PathBuf {
1723        self.root.join(WORKTREE_DIR).join(name)
1724    }
1725
1726    /// Isolate an issue so a failed run cannot poison the next one's base.
1727    pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1728        let branch = self.branch_for_issue(issue);
1729        let path = self.worktree_path(&format!("issue-{issue}"));
1730
1731        self.refuse_issue_branch_rebuild(issue, base)?;
1732        self.refuse_dirty_worktree(
1733            &path,
1734            &format!("worktree for issue #{issue}"),
1735            Disposable::BuildOutput,
1736        )?;
1737
1738        if !self.branch_deletion_is_safe(&branch)? {
1739            bail!(
1740                "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1741                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1742                 before retrying."
1743            );
1744        }
1745
1746        if !self.remove_worktree_at(&path)? {
1747            bail!(
1748                "the existing worktree for issue #{issue} could not be removed safely. Its \
1749                 branch was kept."
1750            );
1751        }
1752        if !self.delete_branch_if_safe(&branch)? {
1753            bail!(
1754                "the existing branch {branch} changed or remained checked out while its \
1755                 worktree was being rebuilt. It was kept."
1756            );
1757        }
1758
1759        if let Some(parent) = path.parent() {
1760            std::fs::create_dir_all(parent)
1761                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1762        }
1763
1764        let path_str = path.display().to_string();
1765        let remote_start = format!("origin/{base}");
1766        let created = self
1767            .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1768            .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1769
1770        // Recorded on both paths: an unrecorded branch is one cleanup will
1771        // never remove, and the fallback creates a branch just the same.
1772        created.map_err(|e| {
1773            spar_err!(
1774                "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1775                 and does `origin` exist?",
1776                e.last_line()
1777            )
1778        })?;
1779        self.record_branch(&branch, "issue", issue);
1780        Ok((path, branch))
1781    }
1782
1783    /// Refuse to reset the local or remote branch assigned to an issue when it
1784    /// carries work that no pull request preserves.
1785    ///
1786    /// Both linked-worktree mode and shared-checkout mode rebuild the same
1787    /// branch name. Keeping the guard here prevents either path from silently
1788    /// replacing recovery commits left by an earlier run.
1789    pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1790        let branch = self.branch_for_issue(issue);
1791        let base_remote_ref = format!("refs/heads/{base}");
1792        let base_tracking_ref = format!("refs/remotes/origin/{base}");
1793        let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1794        self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1795            .map_err(|e| {
1796                spar_err!(
1797                    "could not refresh origin/{base} before checking issue #{issue}: {}",
1798                    e.last_line()
1799                )
1800            })?;
1801
1802        if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
1803            let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
1804            if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
1805                bail!(
1806                    "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
1807                     pull request accounts for them. Rebuilding it would force push over that \
1808                     work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
1809                     it, or delete it with `git push origin --delete {branch}` if the remote \
1810                     branch is no longer needed."
1811                );
1812            }
1813        }
1814
1815        // The same question of the fork a refused push would go to, asked here
1816        // rather than after an agent has spent its call implementing.
1817        if self.fork_when_denied && self.origin_refuses_push() {
1818            match self.existing_fork() {
1819                Ok(Some(fork)) => {
1820                    self.fork_branch_lease(&fork, &branch, base)?;
1821                }
1822                Ok(None) => {}
1823                Err(e) => logdim!("{e} It is checked again before anything is pushed to it."),
1824            }
1825        }
1826
1827        let local_ref = format!("refs/heads/{branch}");
1828        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1829            let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
1830            let recorded_pr = self
1831                .known_branches()
1832                .get(&branch)
1833                .is_some_and(|record| record.kind == "pr");
1834            let preserved = ahead == 0
1835                || if recorded_pr {
1836                    self.local_branch_is_preserved(&branch)?
1837                } else {
1838                    self.pull_request_holds(&branch, &local_ref, base)
1839                };
1840            if !preserved {
1841                let listed = self
1842                    .commit_lines(&self.root, &local_ref, base)
1843                    .iter()
1844                    .map(|line| format!("  {line}"))
1845                    .collect::<Vec<_>>()
1846                    .join("\n");
1847                bail!(
1848                    "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
1849                     no pull request preserves them. Rebuilding it would delete the only copy.\n\
1850                     {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
1851                     it, or delete it with `git branch -D {branch}` if it is stale."
1852                );
1853            }
1854        }
1855        Ok(())
1856    }
1857
1858    fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
1859        let live_ref = format!("refs/heads/{branch}");
1860        let tracking_ref = format!("refs/remotes/origin/{branch}");
1861        let listed = self
1862            .git(&["ls-remote", "--heads", "origin", &live_ref])
1863            .map_err(|e| {
1864                spar_err!(
1865                    "could not verify whether origin/{branch} still exists: {}",
1866                    e.last_line()
1867                )
1868            })?;
1869
1870        if remote_head_oid(&listed, &live_ref)?.is_some() {
1871            let refspec = format!("+{live_ref}:{tracking_ref}");
1872            self.git(&["fetch", "--no-tags", "origin", &refspec])
1873                .map_err(|e| {
1874                    spar_err!(
1875                        "origin/{branch} exists but its tracking ref could not be refreshed: {}",
1876                        e.last_line()
1877                    )
1878                })?;
1879            if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1880                bail!("origin/{branch} was fetched but its tracking ref is missing");
1881            }
1882            return Ok(Some(tracking_ref));
1883        }
1884
1885        if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1886            return Ok(None);
1887        }
1888        let expected = self
1889            .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
1890            .trim()
1891            .to_string();
1892        self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
1893            .map_err(|e| {
1894                spar_err!(
1895                    "could not discard stale origin/{branch} tracking ref safely: {}",
1896                    e.last_line()
1897                )
1898            })?;
1899        if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1900            bail!(
1901                "origin/{branch} changed while its stale tracking ref was being removed. It was \
1902                 kept."
1903            );
1904        }
1905        Ok(None)
1906    }
1907
1908    /// Whether a pull request from `branch` already holds every commit `refname`
1909    /// has beyond `base`.
1910    ///
1911    /// GitHub serves `refs/pull/N/head` for as long as the repository lives, so
1912    /// commits that reached a pull request outlive the branch they were pushed
1913    /// from. A matching branch name does not establish that on its own: an
1914    /// issue worked twice reuses the name, and the merged pull request from the
1915    /// first round says nothing about where the second round's commits are.
1916    fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
1917        self.prs_for_branch(branch)
1918            .iter()
1919            .any(|pr| self.pr_head_holds(pr.number, refname, base))
1920    }
1921
1922    fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
1923        let head = format!("refs/spar/pr-head/{number}");
1924        let refspec = format!("+refs/pull/{number}/head:{head}");
1925        if self.git(&["fetch", "origin", &refspec]).is_err() {
1926            return false;
1927        }
1928        let held = self.commits_held_by(refname, base, &head);
1929        self.git_try(&["update-ref", "-d", &head]);
1930        held
1931    }
1932
1933    pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
1934        let argv = vec![
1935            "git".to_string(),
1936            "merge-base".to_string(),
1937            "--is-ancestor".to_string(),
1938            older.to_string(),
1939            newer.to_string(),
1940        ];
1941        let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
1942        match out.code {
1943            0 => Ok(true),
1944            1 => Ok(false),
1945            _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
1946        }
1947    }
1948
1949    fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
1950        let head = format!("refs/spar/pr-head/{number}");
1951        let refspec = format!("+refs/pull/{number}/head:{head}");
1952        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1953            spar_err!(
1954                "could not verify the immutable head of PR #{number}: {}",
1955                e.last_line()
1956            )
1957        })?;
1958        let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
1959        self.git_try(&["update-ref", "-d", &head]);
1960        held
1961    }
1962
1963    fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
1964        let text = self.gh(&[
1965            "pr",
1966            "list",
1967            "--head",
1968            branch,
1969            "--state",
1970            "all",
1971            "--json",
1972            "number,url,title",
1973        ])?;
1974        serde_json::from_str(text.trim())
1975            .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
1976    }
1977
1978    fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
1979        let branch_ref = format!("refs/heads/{branch}");
1980        if record.kind == "pr" {
1981            return self.pr_head_contains_checked(record.number, &branch_ref);
1982        }
1983        let prs = self.branch_prs_checked(branch)?;
1984        if prs.is_empty() {
1985            return Ok(false);
1986        }
1987        for pr in prs {
1988            if self.pr_head_contains_checked(pr.number, &branch_ref)? {
1989                return Ok(true);
1990            }
1991        }
1992        Ok(false)
1993    }
1994
1995    fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
1996        let local_ref = format!("refs/heads/{branch}");
1997        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1998            return Ok(true);
1999        }
2000        let oid = self
2001            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2002            .trim()
2003            .to_string();
2004        let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
2005        if !durable_tip {
2006            let remote_ref = format!("refs/heads/{branch}");
2007            let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
2008            durable_tip = remote.lines().any(|line| {
2009                line.split_whitespace()
2010                    .next()
2011                    .is_some_and(|remote_oid| remote_oid == oid)
2012            });
2013        }
2014        if !durable_tip {
2015            if let Some(record) = self.known_branches().get(branch) {
2016                durable_tip = self.branch_is_preserved_checked(branch, record)?;
2017            }
2018        }
2019        if !durable_tip {
2020            return Ok(false);
2021        }
2022        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2023    }
2024
2025    /// Delete a branch only if its exact current tip and reflog are still safe.
2026    /// The expected old value makes a concurrent ref update fail instead of
2027    /// deleting work that appeared after the preservation check.
2028    fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
2029        let local_ref = format!("refs/heads/{branch}");
2030        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2031            return Ok(true);
2032        }
2033        let expected = self
2034            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2035            .trim()
2036            .to_string();
2037        if !self.branch_deletion_is_safe(branch)? {
2038            return Ok(false);
2039        }
2040        let checked_out = self
2041            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
2042            .lines()
2043            .any(|line| line == format!("branch {local_ref}"));
2044        if checked_out {
2045            return Ok(false);
2046        }
2047        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2048        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2049    }
2050
2051    fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
2052        let local_ref = review_ref(number);
2053        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2054            return Ok(true);
2055        }
2056        let oid = self
2057            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2058            .trim()
2059            .to_string();
2060        if !self.pr_head_contains_checked(number, &local_ref)? {
2061            return Ok(false);
2062        }
2063        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2064    }
2065
2066    fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
2067        let local_ref = review_ref(number);
2068        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2069            return Ok(true);
2070        }
2071        let expected = self
2072            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2073            .trim()
2074            .to_string();
2075        if !self.review_ref_deletion_is_safe(number)? {
2076            return Ok(false);
2077        }
2078        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2079        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2080    }
2081
2082    /// Whether `other` already contains every commit `branch` has beyond
2083    /// `base`. False when either ref fails to resolve, so a ref that is not
2084    /// there cannot vouch for anything.
2085    pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
2086        let range = format!("{}..{branch}", self.base_ref(&self.root, base));
2087        self.git_try(&["rev-list", "--count", &range, "--not", other])
2088            .trim()
2089            == "0"
2090    }
2091
2092    pub fn worktree_remove(&self, issue: i64) -> bool {
2093        let path = self.worktree_path(&format!("issue-{issue}"));
2094        match self.remove_worktree_at(&path) {
2095            Ok(removed) => removed,
2096            Err(error) => {
2097                logdim!(
2098                    "kept {} because removal did not reach a confirmed quiet point: {}",
2099                    path.display(),
2100                    error.last_line()
2101                );
2102                false
2103            }
2104        }
2105    }
2106
2107    /// Verify both the common Git directory and the worktree top level.
2108    ///
2109    /// A stale worktree entry is not ownership proof. An unrelated repository
2110    /// can later occupy the same path and must survive cleanup.
2111    fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
2112        let wanted = std::fs::canonicalize(path)
2113            .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
2114        // Every SPAR worktree path is built from the canonical repository root.
2115        // A different canonical path therefore means the final component or
2116        // one of its parents is a symlink. Passing that alias to `git worktree
2117        // remove` can delete the worktree at its real target.
2118        if wanted != path {
2119            return Ok(false);
2120        }
2121        let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
2122            let raw = PathBuf::from(value.trim());
2123            let joined = if raw.is_absolute() {
2124                raw
2125            } else {
2126                cwd.join(raw)
2127            };
2128            std::fs::canonicalize(&joined)
2129                .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
2130        };
2131        let expected =
2132            self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
2133        let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
2134        let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
2135        let expected = resolve(&self.root, &expected)?;
2136        let actual = resolve(path, &actual)?;
2137        let top = resolve(path, &top)?;
2138        Ok(expected == actual && top == wanted)
2139    }
2140
2141    /// Remove only a worktree that belongs to this repository.
2142    ///
2143    /// The path sits under a predictable directory, but that does not establish
2144    /// ownership. A clean independent repository at the same path must survive
2145    /// even when `git worktree remove` rejects it.
2146    fn remove_worktree_at_with_force(
2147        &self,
2148        path: &Path,
2149        force: bool,
2150        disposable: Disposable,
2151    ) -> Result<bool> {
2152        let existed = path.exists();
2153        if path.exists() {
2154            match self.worktree_belongs_to_repo(path) {
2155                Ok(true) => {}
2156                Ok(false) => {
2157                    logdim!(
2158                        "kept {} because it is not a worktree owned by this repository",
2159                        path.display()
2160                    );
2161                    return Ok(false);
2162                }
2163                Err(e) => {
2164                    logdim!(
2165                        "kept {} because its worktree ownership could not be verified: {}",
2166                        path.display(),
2167                        e.last_line()
2168                    );
2169                    return Ok(false);
2170                }
2171            }
2172            if !force {
2173                match self.has_recoverable_work(path, disposable) {
2174                    Ok(true) => {
2175                        logdim!(
2176                            "kept {} because it contains recoverable files or repository state",
2177                            path.display()
2178                        );
2179                        return Ok(false);
2180                    }
2181                    Err(e) => {
2182                        logdim!(
2183                            "kept {} because its recoverable state could not be checked: {}",
2184                            path.display(),
2185                            e.last_line()
2186                        );
2187                        return Ok(false);
2188                    }
2189                    Ok(false) => {}
2190                }
2191            }
2192        }
2193        // Git refuses to remove a worktree holding any untracked file, which is
2194        // the same question SPAR has just answered in more detail: under
2195        // `ReviewOutput` the checks above have already established that nothing
2196        // there is worth keeping. `--force` here is that answer being carried
2197        // out, not the check being skipped, which is what `force` means.
2198        let path_str = path.display().to_string();
2199        let command_ok = if force || disposable == Disposable::ReviewOutput {
2200            self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
2201        } else {
2202            self.git_try_without_automation(&["worktree", "remove", &path_str])?
2203        };
2204        Ok((command_ok || !existed) && !path.exists())
2205    }
2206
2207    fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
2208        self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
2209    }
2210
2211    /// Remove a review checkout that is about to be rebuilt from origin.
2212    fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
2213        self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
2214    }
2215
2216    /// Force removal is reserved for the explicit `clean --all` path.
2217    fn remove_worktree_at_force(&self, path: &Path) -> bool {
2218        match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
2219            Ok(removed) => removed,
2220            Err(error) => {
2221                logdim!(
2222                    "kept {} because removal did not reach a confirmed quiet point: {}",
2223                    path.display(),
2224                    error.last_line()
2225                );
2226                false
2227            }
2228        }
2229    }
2230
2231    /// Remove a worktree only after a caller has verified it is unchanged.
2232    ///
2233    /// There is deliberately no force fallback, so Git can still refuse a
2234    /// removal if tracked or non-ignored work appears after the final check.
2235    fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
2236        if path.exists() && !self.worktree_belongs_to_repo(path)? {
2237            bail!(
2238                "{} is not a worktree owned by this repository, so it was kept",
2239                path.display()
2240            );
2241        }
2242        if path.exists() && self.has_recoverable_work(path, disposable)? {
2243            bail!(
2244                "the verified worktree at {} contains recoverable files or repository state. It \
2245                 was kept.",
2246                path.display()
2247            );
2248        }
2249        let path_str = path.display().to_string();
2250        self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
2251            .map_err(|e| {
2252                e.with_message(format!(
2253                    "could not remove the verified worktree at {}: {}. It was kept.",
2254                    path.display(),
2255                    e.last_line()
2256                ))
2257            })?;
2258        Ok(!path.exists())
2259    }
2260
2261    fn refuse_dirty_worktree(
2262        &self,
2263        path: &Path,
2264        label: &str,
2265        disposable: Disposable,
2266    ) -> Result<()> {
2267        if !path.is_dir() {
2268            return Ok(());
2269        }
2270        let has_files = std::fs::read_dir(path)
2271            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2272            .next()
2273            .is_some();
2274        let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
2275            spar_err!(
2276                "could not verify whether the existing {label} at {} belongs to this repository, \
2277                 so it was kept: {}",
2278                path.display(),
2279                e.last_line()
2280            )
2281        })?;
2282        if !owned {
2283            if has_files {
2284                bail!(
2285                    "the existing {label} at {} is not a worktree owned by \
2286                     this repository. Refusing to remove it.",
2287                    path.display()
2288                );
2289            }
2290            return Ok(());
2291        }
2292        if !path.join(".git").exists() {
2293            if has_files {
2294                bail!(
2295                    "the existing {label} at {} is not a readable Git worktree and is not empty. \
2296                     Refusing to remove it.",
2297                    path.display()
2298                );
2299            }
2300            return Ok(());
2301        }
2302        let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2303            spar_err!(
2304                "could not verify whether the existing {label} at {} is clean, so it was kept: \
2305                 {}",
2306                path.display(),
2307                e.last_line()
2308            )
2309        })?;
2310        if dirty {
2311            bail!(
2312                "the existing {label} contains uncommitted changes or ignored files at {}. \
2313                 Rebuilding it would delete those files.\nCommit or recover them before running this \
2314                 command again, or use `spar clean --all` if they are not needed.",
2315                path.display()
2316            );
2317        }
2318        Ok(())
2319    }
2320
2321    /// Check an existing PR branch out into an isolated worktree, from
2322    /// wherever `target` says the branch is published.
2323    pub fn worktree_for_pr(&self, pr: &PrView, target: &PushTarget) -> Result<(PathBuf, String)> {
2324        let head = pr.head_ref_name.clone();
2325        if head.trim().is_empty() {
2326            bail!("PR #{} has no head branch to check out", pr.number);
2327        }
2328        let path = self.worktree_path(&format!("pr-{}", pr.number));
2329        let local = self.branch_for_pr(pr.number);
2330
2331        let unfetched = |e: crate::error::SparError| {
2332            spar_err!(
2333                "could not fetch the branch behind PR #{}: {}",
2334                pr.number,
2335                e.last_line()
2336            )
2337        };
2338        // What the worktree starts from, the ref behind it, and what to call
2339        // it when saying so.
2340        let (start, start_ref, shown) = match target {
2341            PushTarget::Origin => {
2342                self.git(&["fetch", "origin", &head]).map_err(unfetched)?;
2343                let start = format!("origin/{head}");
2344                (start.clone(), format!("refs/remotes/origin/{head}"), start)
2345            }
2346            // A fork has no remote here to track it, so its branch is
2347            // fetched into a ref of spar's own.
2348            PushTarget::Fork(fork) => {
2349                let start_ref = format!("refs/spar/fork/{head}");
2350                let refspec = format!("+refs/heads/{head}:{start_ref}");
2351                self.git(&["fetch", "--no-tags", &fork.url, &refspec])
2352                    .map_err(unfetched)?;
2353                (start_ref.clone(), start_ref, target.describe(&head))
2354            }
2355        };
2356        let local_ref = format!("refs/heads/{local}");
2357        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2358            let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2359            if unpushed > 0 {
2360                bail!(
2361                    "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2362                     on {shown}. Rebuilding it would delete their branch.\nInspect the worktree at \
2363                     {} and push or recover those commits before running this command again.",
2364                    pr.number,
2365                    path.display()
2366                );
2367            }
2368        }
2369        self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2370        self.refuse_dirty_worktree(
2371            &path,
2372            &format!("worktree for PR #{}", pr.number),
2373            Disposable::BuildOutput,
2374        )?;
2375        if !self.branch_deletion_is_safe(&local)? {
2376            bail!(
2377                "the existing branch {local} has a tip or reflog-only commit that no surviving \
2378                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2379                 before retrying."
2380            );
2381        }
2382        if !self.remove_worktree_at(&path)? {
2383            bail!(
2384                "the existing worktree for PR #{} could not be removed safely. Its branch was \
2385                 kept.",
2386                pr.number
2387            );
2388        }
2389        if !self.delete_branch_if_safe(&local)? {
2390            bail!(
2391                "the existing branch {local} changed or remained checked out while the PR \
2392                 worktree was being rebuilt. It was kept."
2393            );
2394        }
2395
2396        let path_str = path.display().to_string();
2397        self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2398        self.record_branch(&local, "pr", pr.number);
2399        Ok((path, head))
2400    }
2401
2402    /// A retry needs a fresh checkout, not permission to delete ignored data.
2403    /// Keep that data and its branch when all commits are already published.
2404    fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2405        if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2406            return Ok(());
2407        }
2408        let current =
2409            self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2410        if current.trim() != branch
2411            || self.has_uncommitted_changes(path)?
2412            || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2413        {
2414            return Ok(());
2415        }
2416        static NEXT: AtomicUsize = AtomicUsize::new(0);
2417        let (name, retained) = loop {
2418            let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2419            let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2420            let retained = self.worktree_path(&name);
2421            if !retained
2422                .try_exists()
2423                .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2424                && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2425            {
2426                break (name, retained);
2427            }
2428        };
2429        // Neither command overwrites an existing destination. The branch rename
2430        // preserves its reflog, and the worktree move preserves every file.
2431        self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2432        if let Err(error) = self.git_at_without_automation(
2433            &self.root,
2434            &[
2435                "worktree",
2436                "move",
2437                &path.display().to_string(),
2438                &retained.display().to_string(),
2439            ],
2440        ) {
2441            // A locked worktree or one with submodules cannot always be moved.
2442            // Restore the branch name so a failed move does not strand a retry.
2443            let rollback =
2444                self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2445            let detail = rollback
2446                .err()
2447                .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2448                .unwrap_or_default();
2449            bail!(
2450                "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2451                retained.display(),
2452                error.last_line()
2453            );
2454        }
2455        logdim!(
2456            "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2457            retained.display()
2458        );
2459        Ok(())
2460    }
2461
2462    /// Check a pull request's head out read only, detached, with no branch.
2463    ///
2464    /// Fetches `refs/pull/N/head`, which GitHub serves for every pull request
2465    /// including one from a fork whose branch is not in this repository at all.
2466    /// That is what makes reviewing an outside contribution possible when
2467    /// pushing to it is not.
2468    ///
2469    /// Detached on purpose. Review only mode has nothing to push, and a branch
2470    /// would only invite something to try.
2471    pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2472        let path = self.worktree_path(&format!("review-{number}"));
2473        let local_ref = review_ref(number);
2474        let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2475
2476        self.refuse_review_worktree_changes(number)?;
2477
2478        if let Some(parent) = path.parent() {
2479            std::fs::create_dir_all(parent)
2480                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2481        }
2482        // Removed before the fetch, while `local_ref` still holds the head
2483        // this checkout is at. A force push moves the ref off that commit,
2484        // and the checkout's reflog would then name a commit no ref holds,
2485        // which the removal check rightly keeps as local work.
2486        if !self.remove_review_worktree_at(&path)? {
2487            bail!(
2488                "the existing review worktree for PR #{number} could not be removed safely. Its \
2489                 reference was kept."
2490            );
2491        }
2492
2493        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2494            spar_err!(
2495                "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2496                 every pull request, so this usually means the number is wrong or `origin` does \
2497                 not point at the repository the PR is on.",
2498                e.last_line()
2499            )
2500        })?;
2501
2502        let path_str = path.display().to_string();
2503        self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2504        Ok(path)
2505    }
2506
2507    fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2508        let path = self.worktree_path(&format!("review-{number}"));
2509        if !path.is_dir() {
2510            return Ok(());
2511        }
2512        let local_ref = review_ref(number);
2513        if !self.worktree_belongs_to_repo(&path)? {
2514            return Ok(());
2515        }
2516        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2517            bail!(
2518                "the existing review worktree for PR #{number} has no recorded head at \
2519                 {local_ref}. Refusing to rebuild {}.",
2520                path.display()
2521            );
2522        }
2523        let worktree_head = self.head_oid_checked(&path)?;
2524        let recorded_head = self
2525            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2526            .trim()
2527            .to_string();
2528        if worktree_head != recorded_head {
2529            bail!(
2530                "the existing review worktree for PR #{number} has a local commit that is not on \
2531                 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2532                 {} before retrying.",
2533                path.display()
2534            );
2535        }
2536        self.report_recovery_markers(&path);
2537        self.refuse_dirty_worktree(
2538            &path,
2539            &format!("review worktree for PR #{number}"),
2540            Disposable::ReviewOutput,
2541        )?;
2542        Ok(())
2543    }
2544
2545    /// Read out what an earlier run refused to explain away, before rebuilding
2546    /// over it.
2547    ///
2548    /// The marker is the only record of why a call's answer was thrown out, and
2549    /// a review checkout is rebuilt on the next run. Saying it here is what
2550    /// keeps the file from being the thing that quietly blocks the command
2551    /// instead.
2552    fn report_recovery_markers(&self, path: &Path) {
2553        let Ok(entries) = std::fs::read_dir(path) else {
2554            return;
2555        };
2556        for entry in entries.flatten() {
2557            let name = entry.file_name();
2558            let Some(name) = name.to_str() else { continue };
2559            if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2560                continue;
2561            }
2562            match std::fs::read_to_string(entry.path()) {
2563                Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2564                Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2565            }
2566        }
2567    }
2568
2569    /// A worktree for one part of a split, on a new branch off `start`.
2570    ///
2571    /// `start` is the base branch for independent parts and the previous part's
2572    /// branch for stacked ones, which is the only difference between the two
2573    /// shapes at this level.
2574    ///
2575    /// The branch is whatever name was free, which is why it is returned rather
2576    /// than derived by the caller. Splitting the same pull request a second
2577    /// time would otherwise target the branch behind the first run's pull
2578    /// request. Split pushes are create-only and would refuse that target, but
2579    /// a repeated split still needs distinct branches rather than a name that
2580    /// can never be created.
2581    pub fn worktree_for_split(
2582        &self,
2583        parent: i64,
2584        index: usize,
2585        start: &str,
2586    ) -> Result<(PathBuf, String)> {
2587        let slot = self.free_split_slot(parent, index)?;
2588        let branch = format!("{}{slot}", self.branch_prefix);
2589        let path = self.worktree_path(&slot);
2590
2591        if let Some(dir) = path.parent() {
2592            std::fs::create_dir_all(dir)
2593                .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2594        }
2595        self.refuse_dirty_worktree(
2596            &path,
2597            &format!("worktree for part {index} of PR #{parent}"),
2598            Disposable::BuildOutput,
2599        )?;
2600        // The name is free, so there is no branch to delete. A directory can
2601        // still be in the way, left by a worktree that was pruned from git's
2602        // records without being removed from disk.
2603        if !self.remove_worktree_at(&path)? {
2604            bail!(
2605                "the existing worktree for part {index} of PR #{parent} could not be removed \
2606                 safely. No branch was created."
2607            );
2608        }
2609
2610        let path_str = path.display().to_string();
2611        self.git(&["worktree", "add", "-b", &branch, &path_str, start])
2612            .map_err(|e| {
2613                spar_err!(
2614                    "could not create a worktree for part {index} of #{parent}. {}",
2615                    e.last_line()
2616                )
2617            })?;
2618        // Recorded before anything else can fail. An unrecorded branch is one
2619        // `prune_branches` will never remove.
2620        self.record_branch(&branch, "split", parent);
2621        Ok((path, branch))
2622    }
2623
2624    /// The first part branch nothing is already sitting on.
2625    ///
2626    /// Origin as well as local, because a part's branch outlives the local one:
2627    /// a second split of the same pull request finds its own earlier branches
2628    /// deleted here but alive on origin, where the pull requests that reviewed
2629    /// them still point at them.
2630    fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2631        for attempt in 1..=SPLIT_SLOTS {
2632            let slot = split_slot(parent, index, attempt);
2633            let branch = format!("{}{slot}", self.branch_prefix);
2634            self.git_try(&["fetch", "origin", &branch]);
2635            if !self.rev_exists(&self.root, &branch)
2636                && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2637            {
2638                return Ok(slot);
2639            }
2640        }
2641        bail!(
2642            "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2643             names are all taken. Inspect the existing branches and child pull requests. Finish \
2644             recording the earlier split, or remove every retained local worktree and branch, \
2645             child pull request, and remote split branch before starting over.",
2646            self.branch_for_split(parent, index)
2647        )
2648    }
2649
2650    /// Whether a previous attempt pushed any branch for this split.
2651    ///
2652    /// The parent comment is the normal retry marker. A branch is the fallback
2653    /// when that comment or the pull request creation failed after the push.
2654    /// Reading origin directly makes the guard survive a fresh clone.
2655    pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2656        let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2657        Ok(!self
2658            .git(&["ls-remote", "--heads", "origin", &pattern])?
2659            .trim()
2660            .is_empty())
2661    }
2662
2663    /// Throw one part away: its worktree, its branch, and its record.
2664    ///
2665    /// For a part that would not stand on its own. Nothing has been pushed at
2666    /// that point, so this leaves no trace anywhere but the log. Takes what
2667    /// `worktree_for_split` returned, since the name it settled on is not
2668    /// derivable from the parent and the index.
2669    pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2670        match self.branch_deletion_is_safe(branch) {
2671            Ok(true) => {}
2672            Ok(false) => {
2673                logdim!(
2674                    "kept {branch} and {} because no surviving ref preserves its tip",
2675                    dir.display()
2676                );
2677                return;
2678            }
2679            Err(error) => {
2680                logdim!(
2681                    "kept {branch} and {} because preservation could not be verified: {}",
2682                    dir.display(),
2683                    error.last_line()
2684                );
2685                return;
2686            }
2687        }
2688        match self.remove_worktree_at(dir) {
2689            Ok(true) => match self.delete_branch_if_safe(branch) {
2690                Ok(true) => self.forget_branch(branch),
2691                Ok(false) => {
2692                    logdim!("kept {branch} because its tip or reflog changed before deletion")
2693                }
2694                Err(error) => logdim!(
2695                    "kept {branch} because deletion safety could not be rechecked: {}",
2696                    error.last_line()
2697                ),
2698            },
2699            Ok(false) => {}
2700            Err(error) => logdim!(
2701                "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
2702                dir.display(),
2703                error.last_line()
2704            ),
2705        }
2706    }
2707
2708    /// Discard one exact mechanical slice that the split workflow just made.
2709    ///
2710    /// Unlike ordinary release, this intentionally removes an unpushed commit.
2711    /// The caller supplies the exact disposable tip, and every file, worktree,
2712    /// ownership, and ref check must still match before anything is removed.
2713    pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
2714        let record = self.known_branches().get(branch).cloned();
2715        if record.is_none_or(|record| record.kind != "split") {
2716            logdim!("kept {branch} because no split branch record proves ownership");
2717            return false;
2718        }
2719        let local_ref = format!("refs/heads/{branch}");
2720        let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
2721            Ok(value) => value.trim().to_string(),
2722            Err(error) => {
2723                logdim!(
2724                    "kept {branch} because its tip could not be checked: {}",
2725                    error.last_line()
2726                );
2727                return false;
2728            }
2729        };
2730        if expected != disposable_head {
2731            logdim!("kept {branch} because it moved beyond the disposable slice");
2732            return false;
2733        }
2734        match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
2735            Ok(true) => {}
2736            Ok(false) => {
2737                logdim!(
2738                    "kept {branch} because its reflog contains work outside the disposable slice"
2739                );
2740                return false;
2741            }
2742            Err(error) => {
2743                logdim!(
2744                    "kept {branch} because its reflog could not be checked: {}",
2745                    error.last_line()
2746                );
2747                return false;
2748            }
2749        }
2750        match self.head_oid_checked(dir) {
2751            Ok(head) if head == disposable_head => {}
2752            Ok(_) => {
2753                logdim!(
2754                    "kept {branch} and {} because the worktree moved beyond the disposable slice",
2755                    dir.display()
2756                );
2757                return false;
2758            }
2759            Err(error) => {
2760                logdim!(
2761                    "kept {branch} and {} because its head could not be checked: {}",
2762                    dir.display(),
2763                    error.last_line()
2764                );
2765                return false;
2766            }
2767        }
2768        match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
2769            Ok(true) => {}
2770            Ok(false) => return false,
2771            Err(error) => {
2772                logdim!(
2773                    "kept {branch} and {} because the disposable slice could not be verified: {}",
2774                    dir.display(),
2775                    error.last_line()
2776                );
2777                return false;
2778            }
2779        }
2780        if let Err(error) =
2781            self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
2782        {
2783            logdim!(
2784                "kept {branch} because its exact disposable tip could not be deleted: {}",
2785                error.last_line()
2786            );
2787            return false;
2788        }
2789        match self.exact_ref_exists_checked(&self.root, &local_ref) {
2790            Ok(false) => {
2791                self.forget_branch(branch);
2792                true
2793            }
2794            Ok(true) => {
2795                logdim!("kept {branch} because its ref still exists after deletion");
2796                false
2797            }
2798            Err(error) => {
2799                logdim!(
2800                    "kept the branch record for {branch} because deletion could not be verified: {}",
2801                    error.last_line()
2802                );
2803                false
2804            }
2805        }
2806    }
2807
2808    pub fn release_review_worktree(&self, number: i64) {
2809        let path = self.worktree_path(&format!("review-{number}"));
2810        match self.review_ref_deletion_is_safe(number) {
2811            Ok(true) => {}
2812            Ok(false) => {
2813                logdim!(
2814                    "kept {} because no surviving ref preserves its review history",
2815                    path.display()
2816                );
2817                return;
2818            }
2819            Err(error) => {
2820                logdim!(
2821                    "kept {} because review history could not be verified: {}",
2822                    path.display(),
2823                    error.last_line()
2824                );
2825                return;
2826            }
2827        }
2828        match self.remove_worktree_at(&path) {
2829            Ok(true) => match self.delete_review_ref_if_safe(number) {
2830                Ok(true) => {}
2831                Ok(false) => logdim!(
2832                    "kept {} because its review history changed before deletion",
2833                    review_ref(number)
2834                ),
2835                Err(error) => logdim!(
2836                    "kept {} because deletion safety could not be rechecked: {}",
2837                    review_ref(number),
2838                    error.last_line()
2839                ),
2840            },
2841            Ok(false) => {}
2842            Err(error) => logdim!(
2843                "kept {} because removal did not reach a confirmed quiet point: {}",
2844                path.display(),
2845                error.last_line()
2846            ),
2847        }
2848    }
2849
2850    /// Release a read-only review checkout only when every observed part of
2851    /// its Git state still matches the checkpoint captured before the calls.
2852    pub(crate) fn release_review_worktree_checked(
2853        &self,
2854        number: i64,
2855        checkpoint: &WorktreeCheckpoint,
2856    ) -> Result<()> {
2857        let path = self.worktree_path(&format!("review-{number}"));
2858        self.require_unchanged_worktree(
2859            &path,
2860            checkpoint,
2861            &format!("review worktree for PR #{number}"),
2862        )?;
2863        if !self.review_ref_deletion_is_safe(number)? {
2864            bail!(
2865                "the review reference for PR #{number} has reflog-only recovery history. The \
2866                 worktree and reference were kept."
2867            );
2868        }
2869        if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
2870            bail!(
2871                "the verified review worktree at {} could not be removed, so its reference was \
2872                 kept",
2873                path.display()
2874            );
2875        }
2876        if !self.delete_review_ref_if_safe(number)? {
2877            bail!(
2878                "the review reference for PR #{number} changed before deletion. The reference was \
2879                 kept."
2880            );
2881        }
2882        Ok(())
2883    }
2884
2885    pub fn release_pr_worktree(&self, number: i64) -> bool {
2886        let path = self.worktree_path(&format!("pr-{number}"));
2887        let local = self.branch_for_pr(number);
2888        match self.branch_deletion_is_safe(&local) {
2889            Ok(true) => {}
2890            Ok(false) => {
2891                logdim!(
2892                    "kept {local} and {} because no surviving ref preserves its tip",
2893                    path.display()
2894                );
2895                return false;
2896            }
2897            Err(error) => {
2898                logdim!(
2899                    "kept {local} and {} because preservation could not be verified: {}",
2900                    path.display(),
2901                    error.last_line()
2902                );
2903                return false;
2904            }
2905        }
2906        match self.remove_worktree_at(&path) {
2907            Ok(true) => match self.delete_branch_if_safe(&local) {
2908                Ok(true) => {
2909                    self.forget_branch(&local);
2910                    true
2911                }
2912                Ok(false) => {
2913                    logdim!("kept {local} because its tip or reflog changed before deletion");
2914                    false
2915                }
2916                Err(error) => {
2917                    logdim!(
2918                        "kept {local} because deletion safety could not be rechecked: {}",
2919                        error.last_line()
2920                    );
2921                    false
2922                }
2923            },
2924            Ok(false) => false,
2925            Err(error) => {
2926                logdim!(
2927                    "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
2928                    path.display(),
2929                    error.last_line()
2930                );
2931                false
2932            }
2933        }
2934    }
2935
2936    // -- branch state -----------------------------------------------------
2937
2938    /// What to diff against: the remote tracking branch when it resolves, the
2939    /// local branch when it does not.
2940    ///
2941    /// This is not a nicety. Every "did the agent do anything" check hangs off
2942    /// this ref, and `git log` against a ref that does not exist fails silently
2943    /// and reads as "no commits". A checkout whose `origin/main` was never
2944    /// fetched would report every implementation as abandoned and throw the
2945    /// work away.
2946    pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
2947        let remote = format!("origin/{base}");
2948        if self.rev_exists(cwd, &remote) {
2949            return remote;
2950        }
2951        if self.rev_exists(cwd, base) {
2952            logdim!("origin/{base} does not resolve, comparing against local {base}");
2953            return base.to_string();
2954        }
2955        logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
2956        remote
2957    }
2958
2959    fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
2960        let spec = format!("{refname}^{{commit}}");
2961        !self
2962            .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
2963            .trim()
2964            .is_empty()
2965    }
2966
2967    pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
2968        let range = format!("{}..HEAD", self.base_ref(cwd, base));
2969        !self
2970            .git_try_at(Some(cwd), &["log", &range, "--oneline"])
2971            .trim()
2972            .is_empty()
2973    }
2974
2975    fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
2976        let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
2977        Ok(found.lines().any(|line| line.trim() == refname))
2978    }
2979
2980    fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
2981        let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
2982        count.trim().parse::<usize>().map_err(|e| {
2983            spar_err!(
2984                "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
2985                count.trim()
2986            )
2987        })
2988    }
2989
2990    pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
2991        let remote = format!("refs/remotes/origin/{base}");
2992        if self.exact_ref_exists_checked(cwd, &remote)? {
2993            return Ok(remote);
2994        }
2995        let local = format!("refs/heads/{base}");
2996        if self.exact_ref_exists_checked(cwd, &local)? {
2997            return Ok(local);
2998        }
2999        bail!("neither origin/{base} nor local branch {base} resolves")
3000    }
3001
3002    pub(crate) fn commit_count_checked(
3003        &self,
3004        cwd: &Path,
3005        refname: &str,
3006        base: &str,
3007    ) -> Result<usize> {
3008        let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
3009        let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
3010        count.trim().parse::<usize>().map_err(|e| {
3011            spar_err!(
3012                "git returned an invalid commit count for {range}: {:?} ({e})",
3013                count.trim()
3014            )
3015        })
3016    }
3017
3018    pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
3019        Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
3020    }
3021
3022    pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
3023        let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
3024        let head = head.trim().to_string();
3025        if head.is_empty() {
3026            bail!("git returned an empty HEAD for {}", cwd.display());
3027        }
3028        Ok(head)
3029    }
3030
3031    /// Whether a recorded SPAR branch's exact tip is retained by a pull request.
3032    ///
3033    /// Pull request head refs remain available after close or merge, so this is
3034    /// stronger than requiring an open pull request or a live remote branch.
3035    pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
3036        let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
3037        self.local_branch_is_preserved(branch.trim())
3038    }
3039
3040    /// Whether a recorded local branch's exact tip is retained by its pull
3041    /// request head, regardless of which branch is currently checked out.
3042    pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
3043        let known = self.known_branches();
3044        let Some(record) = known.get(branch) else {
3045            return Ok(false);
3046        };
3047        self.branch_is_preserved_checked(branch, record)
3048    }
3049
3050    /// Whether tracked, staged, or non-ignored untracked files are uncommitted.
3051    pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
3052        has_uncommitted_work(cwd)
3053    }
3054
3055    /// Whether removing a worktree would delete any local file Git does not
3056    /// reproduce from its commits, including ignored untracked files.
3057    fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
3058        repository_has_recoverable_work_with(cwd, true, disposable)
3059    }
3060
3061    /// Record ignored artifacts that existed before an editing call.
3062    pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
3063        let attributes = attribute_state(cwd)?;
3064        Ok(WorktreeBaseline {
3065            attributes,
3066            ignored_untracked: ignored_untracked_state(cwd)?,
3067            git_state: safe_git_state(cwd)?,
3068        })
3069    }
3070
3071    /// Capture the Git state of a checkout intended to remain read only while
3072    /// external commands inspect it.
3073    pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
3074        let attributes = attribute_state(cwd)?;
3075        Ok(WorktreeCheckpoint {
3076            path: std::fs::canonicalize(cwd)
3077                .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
3078            attributes,
3079            git_state: safe_git_state(cwd)?,
3080            ignored_untracked: ignored_untracked_state(cwd)?,
3081        })
3082    }
3083
3084    /// Require a read-only checkout to match a previously captured checkpoint.
3085    /// Any probe failure is an error because deletion cannot be proven safe.
3086    pub(crate) fn require_unchanged_worktree(
3087        &self,
3088        cwd: &Path,
3089        checkpoint: &WorktreeCheckpoint,
3090        label: &str,
3091    ) -> Result<()> {
3092        let resolved = std::fs::canonicalize(cwd).map_err(|e| {
3093            crate::error::SparError::uncertain_write(format!(
3094                "could not resolve the {label} at {} after inspection: {e}. It was kept.",
3095                cwd.display()
3096            ))
3097        })?;
3098        if resolved != checkpoint.path {
3099            return Err(uncertain_worktree_change(
3100                cwd,
3101                format!(
3102                    "the {label} moved from {} to {} during inspection. It was kept.",
3103                    checkpoint.path.display(),
3104                    resolved.display()
3105                ),
3106            ));
3107        }
3108        let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
3109            uncertain_worktree_change(
3110                cwd,
3111                format!(
3112                    "could not verify attribute files in the {label} at {}: {}. It was kept.",
3113                    cwd.display(),
3114                    e.last_line()
3115                ),
3116            )
3117        })?;
3118        if attributes != checkpoint.attributes {
3119            return Err(uncertain_worktree_change(
3120                cwd,
3121                format!(
3122                    "attribute files in the {label} at {} changed during inspection. It was \
3123                     kept for recovery.",
3124                    cwd.display()
3125                ),
3126            ));
3127        }
3128        let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
3129            uncertain_worktree_change(
3130                cwd,
3131                format!(
3132                    "could not verify the Git state of the {label} at {}: {}. It was kept.",
3133                    cwd.display(),
3134                    e.last_line()
3135                ),
3136            )
3137        })?;
3138        let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
3139            uncertain_worktree_change(
3140                cwd,
3141                format!(
3142                    "could not verify untracked files in the {label} at {}: {}. It was kept.",
3143                    cwd.display(),
3144                    e.last_line()
3145                ),
3146            )
3147        })?;
3148        let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
3149        // Said even when the inspection is accepted, for the same reason it is
3150        // said on an agent call: a path the project ignores is out of scope,
3151        // not unnoticed.
3152        note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
3153        let tracked = checkpoint.git_state.describe_difference(&git_state);
3154        let untracked = (!leavings.condemning.is_empty()).then(|| {
3155            format!(
3156                "untracked file(s) changed: {}",
3157                list_paths(&leavings.condemning)
3158            )
3159        });
3160        let Some(reason) = join_reasons(tracked, untracked) else {
3161            return Ok(());
3162        };
3163        Err(uncertain_worktree_change(
3164            cwd,
3165            format!(
3166                "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
3167                 for recovery.",
3168                cwd.display()
3169            ),
3170        ))
3171    }
3172
3173    /// Accept project-ignored files left by a successful editing call.
3174    /// Ordinary untracked files must already be represented by the commit.
3175    /// Acceptance does not authorize cleanup of the ignored files.
3176    pub(crate) fn refuse_new_ignored_files(
3177        &self,
3178        cwd: &Path,
3179        baseline: &WorktreeBaseline,
3180    ) -> Result<()> {
3181        self.allow_ignored_files(cwd, baseline).map(drop)
3182    }
3183
3184    /// Preserve unexpected output when the editing call did not finish.
3185    pub(crate) fn refuse_failed_edit_leavings(
3186        &self,
3187        cwd: &Path,
3188        baseline: &WorktreeBaseline,
3189    ) -> Result<()> {
3190        self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
3191            .map(drop)
3192    }
3193
3194    /// Reject ordinary untracked changes after a read-only call.
3195    pub(crate) fn refuse_read_only_leavings(
3196        &self,
3197        cwd: &Path,
3198        baseline: &WorktreeBaseline,
3199    ) -> Result<()> {
3200        self.refuse_changed_attributes(cwd, baseline)?;
3201        let after = ignored_untracked_state(cwd).map_err(|e| {
3202            uncertain_worktree_change(
3203                cwd,
3204                format!(
3205                    "could not verify untracked files in {} after a read-only call: {}. The \
3206                     worktree was kept for recovery.",
3207                    cwd.display(),
3208                    e.last_line()
3209                ),
3210            )
3211        })?;
3212        let leavings = baseline.ignored_untracked.read_only_leavings(&after);
3213        if leavings.condemning.is_empty() {
3214            return Ok(());
3215        }
3216        Err(uncertain_worktree_change(
3217            cwd,
3218            format!(
3219                "the read-only call left untracked file(s) in {} that the project does not \
3220                 ignore: {}. The worktree was kept for recovery.",
3221                cwd.display(),
3222                list_paths(&leavings.condemning)
3223            ),
3224        ))
3225    }
3226
3227    /// The ignored paths the check let through, for one report per attempt
3228    /// rather than one per check.
3229    fn allow_ignored_files(
3230        &self,
3231        cwd: &Path,
3232        baseline: &WorktreeBaseline,
3233    ) -> Result<AllowedArtifacts> {
3234        self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
3235    }
3236
3237    fn check_new_ignored_files(
3238        &self,
3239        cwd: &Path,
3240        baseline: &WorktreeBaseline,
3241        allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
3242    ) -> Result<AllowedArtifacts> {
3243        self.refuse_changed_attributes(cwd, baseline)?;
3244        let after = ignored_untracked_state(cwd).map_err(|e| {
3245            uncertain_worktree_change(
3246                cwd,
3247                format!(
3248                    "could not verify untracked files in {} after editing: {}. The worktree was \
3249                     kept for recovery.",
3250                    cwd.display(),
3251                    e.last_line()
3252                ),
3253            )
3254        })?;
3255        let changed = baseline.ignored_untracked.changed_paths(&after);
3256        if changed.is_empty() {
3257            return Ok(AllowedArtifacts::default());
3258        }
3259        let (ignored, changed): (Vec<_>, Vec<_>) = changed
3260            .into_iter()
3261            .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
3262        if changed.is_empty() {
3263            return Ok(AllowedArtifacts::split(ignored, &after));
3264        }
3265        let listed = list_paths(&changed);
3266        Err(uncertain_worktree_change(
3267            cwd,
3268            format!(
3269                "the editing call created or changed untracked or ignored file(s) in {} that \
3270                 cannot be represented by a managed commit: {listed}. The worktree was kept for \
3271                 recovery.",
3272                cwd.display()
3273            ),
3274        ))
3275    }
3276
3277    /// Existing untracked files belong to the checkout owner, even when a call
3278    /// also produces a valid tracked change. Refuse their modification or
3279    /// deletion before accepting the tracked result. Project-ignored files
3280    /// stay outside the commit, wherever the project puts them.
3281    pub(crate) fn refuse_changed_existing_untracked(
3282        &self,
3283        cwd: &Path,
3284        baseline: &WorktreeBaseline,
3285    ) -> Result<()> {
3286        self.check_changed_existing_untracked(cwd, baseline)
3287            .map(drop)
3288    }
3289
3290    fn allow_changed_ignored_artifacts(
3291        &self,
3292        cwd: &Path,
3293        baseline: &WorktreeBaseline,
3294    ) -> Result<AllowedArtifacts> {
3295        self.check_changed_existing_untracked(cwd, baseline)
3296    }
3297
3298    fn check_changed_existing_untracked(
3299        &self,
3300        cwd: &Path,
3301        baseline: &WorktreeBaseline,
3302    ) -> Result<AllowedArtifacts> {
3303        self.refuse_changed_attributes(cwd, baseline)?;
3304        let after = ignored_untracked_state(cwd).map_err(|e| {
3305            uncertain_worktree_change(
3306                cwd,
3307                format!(
3308                    "could not verify existing untracked files in {} after editing: {}. The \
3309                     worktree was kept for recovery.",
3310                    cwd.display(),
3311                    e.last_line()
3312                ),
3313            )
3314        })?;
3315        let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3316        if changed.is_empty() {
3317            return Ok(AllowedArtifacts::default());
3318        }
3319        let (ignored, changed): (Vec<_>, Vec<_>) = changed
3320            .into_iter()
3321            .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3322        if changed.is_empty() {
3323            return Ok(AllowedArtifacts::split(ignored, &after));
3324        }
3325        let listed = list_paths(&changed);
3326        Err(uncertain_worktree_change(
3327            cwd,
3328            format!(
3329                "the editing call changed or deleted existing untracked file(s) in {}: \
3330                 {listed}. The worktree was kept for recovery.",
3331                cwd.display()
3332            ),
3333        ))
3334    }
3335
3336    /// Refuse a byte or mode change that the index did not represent.
3337    ///
3338    /// Clean filters can normalize a working file back to its existing blob,
3339    /// and index flags can hide a change from porcelain status. Comparing the
3340    /// actual tracked files on both sides keeps those bytes from being treated
3341    /// as disposable just because Git has no diff for them.
3342    pub(crate) fn refuse_unrepresented_tracked_changes(
3343        &self,
3344        cwd: &Path,
3345        baseline: &WorktreeBaseline,
3346    ) -> Result<()> {
3347        self.refuse_changed_attributes(cwd, baseline)?;
3348        let after = safe_git_state(cwd).map_err(|e| {
3349            uncertain_worktree_change(
3350                cwd,
3351                format!(
3352                    "could not verify tracked files in {} after editing: {}. The worktree was \
3353                     kept for recovery.",
3354                    cwd.display(),
3355                    e.last_line()
3356                ),
3357            )
3358        })?;
3359        let mut changed = Vec::new();
3360        let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3361            uncertain_worktree_change(
3362                cwd,
3363                format!(
3364                    "could not record untracked files before verifying transformed content in {}: \
3365                     {}. The worktree was kept for recovery.",
3366                    cwd.display(),
3367                    e.last_line()
3368                ),
3369            )
3370        })?;
3371        let mut filter_was_run = false;
3372        let mut filter_problem = None;
3373        let mut repositories: BTreeSet<PathBuf> =
3374            baseline.git_state.repositories.keys().cloned().collect();
3375        repositories.extend(after.repositories.keys().cloned());
3376        'repositories: for repository_path in repositories {
3377            let before_repository = baseline.git_state.repositories.get(&repository_path);
3378            let after_repository = after.repositories.get(&repository_path);
3379            if before_repository.is_none() || after_repository.is_none() {
3380                changed.push(repository_path.clone());
3381                continue;
3382            }
3383            if before_repository.map(|repository| &repository.gitlinks)
3384                != after_repository.map(|repository| &repository.gitlinks)
3385            {
3386                changed.push(repository_path.join("<gitlinks>"));
3387            }
3388            let mut paths = BTreeSet::new();
3389            if let Some(repository) = before_repository {
3390                paths.extend(repository.tracked.keys().cloned());
3391            }
3392            if let Some(repository) = after_repository {
3393                paths.extend(repository.tracked.keys().cloned());
3394            }
3395            for path in paths {
3396                let before = before_repository.and_then(|repository| repository.tracked.get(&path));
3397                let current = after_repository.and_then(|repository| repository.tracked.get(&path));
3398                let worktree_changed =
3399                    before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
3400                let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
3401                    != current.map(|entry| (&entry.index_mode, &entry.index_oid));
3402                if !worktree_changed {
3403                    continue;
3404                }
3405                if !index_changed {
3406                    changed.push(repository_path.join(&path));
3407                    continue;
3408                }
3409                let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
3410                let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
3411                let Some(current_entry) = current else {
3412                    continue;
3413                };
3414                let Some(current_worktree) = current_worktree else {
3415                    continue;
3416                };
3417                let content_changed =
3418                    before_worktree.map(|file| file.content) != Some(current_worktree.content);
3419                let mode_changed = before_worktree.map(|file| file.mode.as_str())
3420                    != Some(current_worktree.mode.as_str());
3421                let repository = cwd.join(&repository_path);
3422                let represented_content = if content_changed {
3423                    filter_was_run = true;
3424                    let result =
3425                        filtered_index_content(&repository, &path, &current_entry.index_oid);
3426                    self.refuse_changed_attributes(cwd, baseline)?;
3427                    match result {
3428                        Ok(expected) => expected == current_worktree.content,
3429                        Err(error) => {
3430                            filter_problem = Some(format!(
3431                                "could not verify transformed content for {:?}: {}",
3432                                repository_path.join(&path),
3433                                error.last_line()
3434                            ));
3435                            false
3436                        }
3437                    }
3438                } else {
3439                    true
3440                };
3441                let represented_mode =
3442                    !mode_changed || current_worktree.mode == current_entry.index_mode;
3443                if !represented_content || !represented_mode {
3444                    changed.push(repository_path.join(&path));
3445                }
3446                if filter_problem.is_some() {
3447                    break 'repositories;
3448                }
3449            }
3450        }
3451        if filter_was_run {
3452            self.refuse_changed_attributes(cwd, baseline)?;
3453            let verified = safe_git_state(cwd).map_err(|e| {
3454                uncertain_worktree_change(
3455                    cwd,
3456                    format!(
3457                        "could not recheck tracked files after verifying transformed content in \
3458                         {}: {}. The worktree was kept for recovery.",
3459                        cwd.display(),
3460                        e.last_line()
3461                    ),
3462                )
3463            })?;
3464            let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3465                uncertain_worktree_change(
3466                    cwd,
3467                    format!(
3468                        "could not recheck untracked files after verifying transformed content \
3469                         in {}: {}. The worktree was kept for recovery.",
3470                        cwd.display(),
3471                        e.last_line()
3472                    ),
3473                )
3474            })?;
3475            if verified != after
3476                || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3477            {
3478                return Err(uncertain_worktree_change(
3479                    cwd,
3480                    "a content filter changed the worktree while SPAR verified the managed \
3481                     commit. The worktree was kept for recovery.",
3482                ));
3483            }
3484            self.refuse_changed_existing_untracked(cwd, baseline)?;
3485        }
3486        if let Some(problem) = filter_problem {
3487            return Err(uncertain_worktree_change(
3488                cwd,
3489                format!("{problem}. The worktree was kept for recovery."),
3490            ));
3491        }
3492        if changed.is_empty() {
3493            return Ok(());
3494        }
3495        let listed = list_paths(&changed);
3496        Err(uncertain_worktree_change(
3497            cwd,
3498            format!(
3499                "the editing call changed tracked working-file bytes, modes, repositories, or \
3500                 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3501                 recovery."
3502            ),
3503        ))
3504    }
3505
3506    pub(crate) fn refuse_changed_attributes(
3507        &self,
3508        cwd: &Path,
3509        baseline: &WorktreeBaseline,
3510    ) -> Result<()> {
3511        let after = attribute_state(cwd).map_err(|e| {
3512            uncertain_worktree_change(
3513                cwd,
3514                format!(
3515                    "could not verify attribute files in {} after editing: {}. The worktree was \
3516                     kept for recovery.",
3517                    cwd.display(),
3518                    e.last_line()
3519                ),
3520            )
3521        })?;
3522        if after == baseline.attributes {
3523            return Ok(());
3524        }
3525        Err(uncertain_worktree_change(
3526            cwd,
3527            format!(
3528                "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
3529                 refused to run a Git operation that could select a new external filter.",
3530                cwd.display()
3531            ),
3532        ))
3533    }
3534
3535    /// Commit a successful editing call from the trusted harness process.
3536    ///
3537    /// Editing sandboxes only need the working tree. They never need writable
3538    /// access to the repository's object database, refs, config, or hooks.
3539    pub(crate) fn commit_pending_changes(
3540        &self,
3541        cwd: &Path,
3542        baseline: &WorktreeBaseline,
3543        preferred_subject: &str,
3544        fallback_subject: &str,
3545    ) -> Result<bool> {
3546        let mut artifacts = IgnoredArtifacts::default();
3547        self.refuse_changed_attributes(cwd, baseline)?;
3548        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3549        refuse_unsafe_index_flags(cwd)?;
3550        if !self.has_uncommitted_changes(cwd)? {
3551            artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3552            artifacts.report(cwd);
3553            return Ok(false);
3554        }
3555        self.stage_managed_changes(cwd, baseline).map_err(|e| {
3556            e.with_message(format!(
3557                "could not stage changes in {}: {}",
3558                cwd.display(),
3559                e.last_line()
3560            ))
3561        })?;
3562        // Git ignore rules decide which files stay outside the commit.
3563        // Whether cleanup can delete them is a separate decision.
3564        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3565        let changed_gitlinks = changed_staged_gitlinks(cwd)?;
3566        if !changed_gitlinks.is_empty() {
3567            let listed = list_paths(&changed_gitlinks);
3568            bail!(
3569                "the editing call added or changed a gitlink at {listed}. It was staged but not \
3570                 committed because the referenced repository objects might exist only inside \
3571                 this worktree. The worktree was kept for recovery."
3572            );
3573        }
3574        let mut subject = self.clean_title(preferred_subject)?;
3575        if subject.trim().is_empty() {
3576            subject = self.clean_title(fallback_subject)?;
3577        }
3578        self.commit_staged_changes(cwd, &subject).map_err(|e| {
3579            e.with_message(format!(
3580                "could not commit changes in {}: {}. The staged files were kept.",
3581                cwd.display(),
3582                e.last_line()
3583            ))
3584        })?;
3585        if has_tracked_or_staged_work(cwd)? {
3586            bail!(
3587                "the commit in {} left additional uncommitted files. They were kept for \
3588                 recovery.",
3589                cwd.display()
3590            );
3591        }
3592        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3593        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3594        artifacts.report(cwd);
3595        Ok(true)
3596    }
3597
3598    fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
3599        let after = ignored_untracked_state(cwd)?;
3600        self.git_at_without_automation(cwd, &["add", "-u"])?;
3601        let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
3602        if paths.is_empty() {
3603            return Ok(());
3604        }
3605        let mut input = Vec::new();
3606        for path in paths {
3607            input.extend(os_str_bytes(path.as_os_str())?);
3608            input.push(0);
3609        }
3610        let argv = git_without_automation_argv(&[
3611            "--literal-pathspecs",
3612            "add",
3613            "--pathspec-from-file=-",
3614            "--pathspec-file-nul",
3615        ]);
3616        proc::run_with_input_bytes(
3617            &argv,
3618            &self.git_opts(Some(cwd), true).stop_descendants(true),
3619            &input,
3620        )?;
3621        Ok(())
3622    }
3623
3624    /// Commit an index prepared by the parent without signing, hooks, or
3625    /// inherited repository automation.
3626    pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
3627        self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
3628            .map(|_| ())
3629    }
3630
3631    /// How many commits `refname` carries that the base does not.
3632    ///
3633    /// Counted from the commits themselves rather than from `commit_subjects`,
3634    /// which drops a commit whose message is empty. The guards in
3635    /// `worktree_add` decide whether to delete a branch on this number, and an
3636    /// empty message must not read as an empty branch.
3637    pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
3638        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3639        self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
3640            .trim()
3641            .parse()
3642            .unwrap_or(0)
3643    }
3644
3645    /// One `hash subject` line per commit `refname` carries that the base does
3646    /// not, oldest first. For showing a person what is on a branch, so the
3647    /// hash keeps a commit with no message from listing as nothing.
3648    pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3649        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3650        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3651            .lines()
3652            .map(str::to_string)
3653            .collect()
3654    }
3655
3656    /// The commits `later` carries that `earlier` does not, oldest first, when
3657    /// `earlier` is genuinely behind it.
3658    ///
3659    /// `None` when it is not an ancestor, which is not the same as nothing
3660    /// having landed. `rewrite_commits_if_needed` rewrites hashes from the first
3661    /// offending commit spar made onward, so a head recorded part way through a
3662    /// round can still be a readable object and no longer be on the branch. `git log` answers that
3663    /// with every commit on the branch, so without the check the one caller
3664    /// would report the whole branch as unread, which is the widest possible
3665    /// wrong answer.
3666    ///
3667    /// No `base_ref` resolution, unlike its neighbours: these are commits rather
3668    /// than branch names, and putting a sha through it logs a fallback line
3669    /// every time.
3670    pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3671        let ancestor = self
3672            .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3673            .is_ok();
3674        if !ancestor {
3675            return None;
3676        }
3677        let range = format!("{earlier}..{later}");
3678        Some(
3679            self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3680                .lines()
3681                .map(str::to_string)
3682                .collect(),
3683        )
3684    }
3685
3686    /// The subjects of the commits `refname` carries that the base does not,
3687    /// oldest first.
3688    pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3689        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3690        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3691            .lines()
3692            .map(str::trim)
3693            .filter(|line| !line.is_empty())
3694            .map(str::to_string)
3695            .collect()
3696    }
3697
3698    /// The paths this checkout changes relative to the base, sorted.
3699    ///
3700    /// A three dot range, matching `diff_stat`: what the branch did, not what
3701    /// the base has done since.
3702    ///
3703    /// `--no-renames` because a rename reported as its destination alone leaves
3704    /// the source out of the list, and a part carrying only the destination
3705    /// would be a copy. As a deletion and an addition it is two paths, which a
3706    /// part can carry together or leave to the leftover report.
3707    ///
3708    /// `-z` because without it git writes paths for display: anything
3709    /// non-ASCII comes back escaped and wrapped in quotes, and that string is
3710    /// not a path. A part built from one carries a pathspec matching no file,
3711    /// so the file never reaches the slice while every list still says the part
3712    /// took it. It also keeps a path with a space at either end intact.
3713    pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
3714        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3715        self.git_try_at(
3716            Some(cwd),
3717            &["diff", "--name-only", "--no-renames", "-z", &range],
3718        )
3719        .split('\0')
3720        .filter(|path| !path.is_empty())
3721        .map(str::to_string)
3722        .collect()
3723    }
3724
3725    /// The files one commit range touched, for checking a claim against the
3726    /// diff rather than against the report that made it.
3727    pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
3728        self.git_try_at(
3729            Some(cwd),
3730            &[
3731                "diff",
3732                "--name-only",
3733                "--no-renames",
3734                "-z",
3735                &format!("{from}..{to}"),
3736            ],
3737        )
3738        .split('\0')
3739        .filter(|path| !path.is_empty())
3740        .map(str::to_string)
3741        .collect()
3742    }
3743
3744    /// Where `refname` left the base: the commit its own change is measured
3745    /// from, and the one a slice of that change has to be taken against.
3746    pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
3747        let base_ref = self.base_ref(cwd, base);
3748        let out = self
3749            .git_at(Some(cwd), &["merge-base", &base_ref, refname])
3750            .map_err(|e| {
3751                spar_err!(
3752                    "could not find where {refname} and {base_ref} diverged. {}",
3753                    e.last_line()
3754                )
3755            })?;
3756        let sha = out.trim().to_string();
3757        if sha.is_empty() {
3758            bail!("{refname} and {base_ref} share no history");
3759        }
3760        Ok(sha)
3761    }
3762
3763    pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
3764        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3765        let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
3766        full.trim().to_string()
3767    }
3768
3769    /// Scrub commit messages that slipped past the prompt.
3770    ///
3771    /// `git filter-branch` calls back into this same binary, so there is no
3772    /// interpreter to find and no second copy of the rules to drift.
3773    ///
3774    /// `floor` is the head spar found when it started, and nothing at or below
3775    /// it is rewritten. The style gate is for what spar writes, and a person's
3776    /// commit message is not that: rewriting one changes a published SHA, which
3777    /// diverges the branch under somebody's own checkout and is exactly the
3778    /// history rewrite this design promises never to do. A `None` floor means
3779    /// every commit above the base is spar's own, which is true of a branch
3780    /// spar created in this invocation and of nothing else.
3781    pub fn rewrite_commits_if_needed(
3782        &self,
3783        cwd: &Path,
3784        base: &str,
3785        floor: Option<&str>,
3786    ) -> Result<()> {
3787        let base_ref = self.base_ref(cwd, base);
3788        let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
3789        self.report_commits_left_alone(cwd, &base_ref, floor);
3790
3791        let range = format!("{floor}..HEAD");
3792        let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
3793
3794        let offenders = raw
3795            .split('\x1e')
3796            .filter_map(|entry| entry.split_once('\0'))
3797            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3798            .count();
3799        if offenders == 0 {
3800            return Ok(());
3801        }
3802        logdim!("{offenders} commit message(s) violated style rules, rewriting");
3803
3804        let exe = self_binary()?;
3805        let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
3806
3807        let argv: Vec<String> = [
3808            "git",
3809            "filter-branch",
3810            "-f",
3811            "--msg-filter",
3812            &filter,
3813            &range,
3814        ]
3815        .iter()
3816        .map(|s| s.to_string())
3817        .collect();
3818        let opts = ExecOpts::new()
3819            .cwd(cwd)
3820            .check(false)
3821            .timeout_secs(600)
3822            .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
3823            .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
3824            .env(
3825                "SPAR_BAN_AI_ATTRIBUTION",
3826                bool_env(self.style.ban_ai_attribution),
3827            );
3828        let _ = proc::run(&argv, &opts);
3829
3830        let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
3831        if !style::violations(&after, &self.style).is_empty() {
3832            bail!(
3833                "commit messages still violate style rules after a rewrite in {}.",
3834                cwd.display()
3835            );
3836        }
3837        Ok(())
3838    }
3839
3840    /// Say out loud when a message spar will not touch breaks the style rules.
3841    ///
3842    /// Silence here would read as "there was nothing to scrub", and the point
3843    /// of the floor is that there was something and spar chose to leave it.
3844    fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
3845        if floor == base_ref {
3846            return;
3847        }
3848        let raw = self.git_try_at(
3849            Some(cwd),
3850            &[
3851                "log",
3852                &format!("{base_ref}..{floor}"),
3853                "--format=%H%x00%B%x1e",
3854            ],
3855        );
3856        let left: Vec<&str> = raw
3857            .split('\x1e')
3858            .filter_map(|entry| entry.split_once('\0'))
3859            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3860            .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
3861            .collect();
3862        if left.is_empty() {
3863            return;
3864        }
3865        logdim!(
3866            "{} commit message(s) already on the branch break the style rules and were left \
3867             alone: {}. spar does not rewrite commits it did not write.",
3868            left.len(),
3869            left.join(", ")
3870        );
3871    }
3872
3873    /// Push by explicit refspec from HEAD, leasing the head spar reviewed.
3874    ///
3875    /// A resumed PR is checked out under a local name (`pr-N`) that does not
3876    /// match its remote branch, so pushing by branch name would resolve the
3877    /// wrong local ref or fail outright.
3878    ///
3879    /// `expected` is the remote head spar last read. A bare `--force-with-lease`
3880    /// leases the implicit tracking ref instead, which is shared across every
3881    /// worktree and is updated by any `git fetch`. The editing agent is a real
3882    /// CLI with shell access, and fetching is a normal thing for it to do: one
3883    /// `git fetch` mid round moves the tracking ref to whatever the remote holds
3884    /// and the lease then passes against a head spar never reviewed, which
3885    /// silently drops somebody's commit. Naming the head makes the push refuse
3886    /// exactly that case.
3887    pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
3888        self.push_to(cwd, &PushTarget::Origin, branch, expected)
3889    }
3890
3891    /// `push`, to wherever the branch was first published.
3892    pub fn push_to(
3893        &self,
3894        cwd: &Path,
3895        target: &PushTarget,
3896        branch: &str,
3897        expected: Option<&str>,
3898    ) -> Result<()> {
3899        let expected = expected.map(str::trim).filter(|head| !head.is_empty());
3900        if let Some(head) = expected {
3901            self.refuse_push_that_drops(cwd, head)?;
3902        }
3903        let pushed = self
3904            .git_push(cwd, target, branch, expected)
3905            .map_err(|e| push_error(target, branch, &e));
3906        self.record_write(pushed)
3907    }
3908
3909    /// Publish a branch created in this invocation, from a fork if origin
3910    /// refuses it.
3911    ///
3912    /// Only a new branch can move. A pull request that already exists has its
3913    /// branch where it is, and pushing it anywhere else would not update it. A
3914    /// new one has no pull request yet, so a fork is just where it is opened
3915    /// from, which is how anybody without write access contributes.
3916    ///
3917    /// One write whichever way it goes. The refusal is not counted as a
3918    /// failure when the fork takes the branch: the run did what it set out to,
3919    /// and a failed write makes the exit status say otherwise.
3920    pub fn publish_new_branch(&self, cwd: &Path, branch: &str, base: &str) -> Result<PushTarget> {
3921        let origin = PushTarget::Origin;
3922        let refused = match self.git_push(cwd, &origin, branch, None) {
3923            Ok(()) => return self.record_write(Ok(origin)),
3924            Err(e) if self.fork_when_denied && push_denial(&e.to_string()).is_some() => e,
3925            Err(e) => {
3926                let err = push_error(&origin, branch, &e);
3927                let err = if push_denial(&e.to_string()).is_some() {
3928                    spar_err!(
3929                        "{err} Set fork_when_denied = true under [loop] to publish it from a fork \
3930                         instead."
3931                    )
3932                } else {
3933                    err
3934                };
3935                return self.record_write(Err(err));
3936            }
3937        };
3938        self.record_write(self.publish_on_fork(cwd, branch, base, &refused))
3939    }
3940
3941    fn publish_on_fork(
3942        &self,
3943        cwd: &Path,
3944        branch: &str,
3945        base: &str,
3946        refused: &crate::error::SparError,
3947    ) -> Result<PushTarget> {
3948        let fork = self.fork().map_err(|e| {
3949            spar_err!(
3950                "{} spar could not publish it from a fork instead: {}",
3951                push_error(&PushTarget::Origin, branch, refused),
3952                e.last_line()
3953            )
3954        })?;
3955        let lease = self.fork_branch_lease(&fork, branch, base)?;
3956        let refused = refused.to_string();
3957        log!(
3958            "{} Publishing {branch} from the fork {} instead.",
3959            push_denial(&refused).unwrap_or("origin refused the push."),
3960            fork.full_name
3961        );
3962        let target = PushTarget::Fork(fork);
3963        // Not `push_to`: a lease on a branch a closed pull request left behind
3964        // replaces it, and that branch was never this HEAD's to contain.
3965        self.git_push(cwd, &target, branch, lease.as_deref())
3966            .map_err(|e| push_error(&target, branch, &e))?;
3967        Ok(target)
3968    }
3969
3970    /// What the first push of `branch` to the fork leases against.
3971    ///
3972    /// Nothing when the fork has no such branch. Its tip when a pull request
3973    /// already holds every commit on it beyond `base`, which is what a closed
3974    /// pull request leaves, since GitHub keeps the branch on the fork. Anything
3975    /// else is work only that branch has, and is refused the way an issue
3976    /// branch on origin is.
3977    fn fork_branch_lease(&self, fork: &Fork, branch: &str, base: &str) -> Result<Option<String>> {
3978        let live_ref = format!("refs/heads/{branch}");
3979        let unreadable = |e: crate::error::SparError| {
3980            spar_err!(
3981                "could not check whether the fork {} already has {branch}. {}",
3982                fork.full_name,
3983                e.last_line()
3984            )
3985        };
3986        let listed = self
3987            .git(&["ls-remote", "--heads", &fork.url, &live_ref])
3988            .map_err(unreadable)?;
3989        let Some(tip) = remote_head_oid(&listed, &live_ref)? else {
3990            return Ok(None);
3991        };
3992        let local = format!("refs/spar/fork/{branch}");
3993        self.git(&[
3994            "fetch",
3995            "--no-tags",
3996            &fork.url,
3997            &format!("+{live_ref}:{local}"),
3998        ])
3999        .map_err(unreadable)?;
4000        let ahead = self.commit_count_checked(&self.root, &local, base);
4001        let held = matches!(ahead, Ok(0)) || self.pull_request_holds(branch, &local, base);
4002        self.git_try(&["update-ref", "-d", &local]);
4003        let ahead = ahead?;
4004        if !held {
4005            bail!(
4006                "the fork {} already has {branch} with {ahead} commit(s) that are not on {base}, \
4007                 and no pull request accounts for them. Publishing over it would lose that work.\n\
4008                 Delete it with `git push {} --delete {branch}` if it is no longer needed, then \
4009                 run spar again.",
4010                fork.full_name,
4011                fork.url
4012            );
4013        }
4014        Ok(Some(tip))
4015    }
4016
4017    fn git_push(
4018        &self,
4019        cwd: &Path,
4020        target: &PushTarget,
4021        branch: &str,
4022        expected: Option<&str>,
4023    ) -> Result<()> {
4024        let refspec = format!("HEAD:refs/heads/{branch}");
4025        let lease = match (expected, target) {
4026            (Some(head), _) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
4027            // A fork is pushed to by URL, so there is no tracking ref for a
4028            // bare lease to read. Leasing against nothing makes the first push
4029            // a create that never moves a branch already there.
4030            (None, PushTarget::Fork(_)) => format!("--force-with-lease=refs/heads/{branch}:"),
4031            (None, PushTarget::Origin) => "--force-with-lease".to_string(),
4032        };
4033        self.git_at(Some(cwd), &["push", &lease, target.remote(), &refspec])
4034            .map(|_| ())
4035    }
4036
4037    /// The authenticated user's fork of this repository, made if there is none.
4038    ///
4039    /// GitHub answers a request to fork with the fork that already exists, so
4040    /// asking is safe either way, but it is a write and it is only made when
4041    /// looking found nothing. A new fork is copied in the background, so it is
4042    /// waited for before anything is pushed to it.
4043    fn fork(&self) -> Result<Fork> {
4044        match self.existing_fork() {
4045            Ok(Some(fork)) => return Ok(fork),
4046            Ok(None) => {}
4047            Err(e) => logdim!("{e} Asking GitHub to fork it, which returns any fork there is."),
4048        }
4049        let answer = self
4050            .gh(&["api", "-X", "POST", "repos/{owner}/{repo}/forks"])
4051            .map_err(|e| spar_err!("could not fork this repository. {}", e.last_line()))?;
4052        let fork = fork_from_api(&answer, &self.origin_push_url())?;
4053        self.wait_for_fork(&fork)?;
4054        Ok(self.fork.get_or_init(|| fork).clone())
4055    }
4056
4057    /// The authenticated user's fork of this repository, if there is one,
4058    /// found without making one.
4059    fn existing_fork(&self) -> Result<Option<Fork>> {
4060        if let Some(fork) = self.fork.get() {
4061            return Ok(Some(fork.clone()));
4062        }
4063        let answer = self
4064            .gh(&[
4065                "api",
4066                "graphql",
4067                "-F",
4068                "owner={owner}",
4069                "-F",
4070                "name={repo}",
4071                "-f",
4072                "query=query($owner: String!, $name: String!) { repository(owner: $owner, name: \
4073                 $name) { forks(first: 1, affiliations: [OWNER]) { nodes { nameWithOwner owner { \
4074                 login } sshUrl url } } } }",
4075                "--jq",
4076                ".data.repository.forks.nodes",
4077            ])
4078            .map_err(|e| {
4079                spar_err!(
4080                    "could not look for a fork of this repository. {}",
4081                    e.last_line()
4082                )
4083            })?;
4084        let found = fork_from_listing(&answer, &self.origin_push_url())?;
4085        Ok(found.map(|fork| self.fork.get_or_init(|| fork).clone()))
4086    }
4087
4088    /// Whether GitHub says the authenticated user cannot push here. False
4089    /// when it cannot say, so an unanswered question skips the early check
4090    /// rather than guessing.
4091    fn origin_refuses_push(&self) -> bool {
4092        self.gh_try(&["api", "repos/{owner}/{repo}", "--jq", ".permissions.push"])
4093            .trim()
4094            == "false"
4095    }
4096
4097    fn origin_push_url(&self) -> String {
4098        self.git_try(&["remote", "get-url", "--push", "origin"])
4099    }
4100
4101    /// Where spar can push a pull request's branch: origin for a branch in
4102    /// this repository, the fork for one on the authenticated user's own fork,
4103    /// such as one spar opened after origin refused it, and nowhere for
4104    /// anybody else's.
4105    pub fn pr_push_target(&self, pr: &PrView) -> Option<PushTarget> {
4106        if !pr.is_cross_repository {
4107            return Some(PushTarget::Origin);
4108        }
4109        let viewer = self.viewer_login().ok()?;
4110        own_fork_target(pr, viewer, &self.origin_push_url())
4111    }
4112
4113    fn wait_for_fork(&self, fork: &Fork) -> Result<()> {
4114        const TRIES: u32 = 30;
4115        let mut tries = 1;
4116        loop {
4117            match self.git(&["ls-remote", &fork.url, "HEAD"]) {
4118                Ok(_) => return Ok(()),
4119                Err(e) if tries >= TRIES => bail!(
4120                    "the fork {} could not be reached after {} seconds. {}",
4121                    fork.full_name,
4122                    TRIES * 2,
4123                    e.last_line()
4124                ),
4125                Err(_) => {
4126                    tries += 1;
4127                    std::thread::sleep(std::time::Duration::from_secs(2));
4128                }
4129            }
4130        }
4131    }
4132
4133    /// Refuse a push whose HEAD does not contain the head spar reviewed.
4134    ///
4135    /// The lease answers the remote moving. This answers the local side moving:
4136    /// an agent that reset or rebased the worktree leaves a HEAD that would
4137    /// publish over the reviewed head rather than on top of it, and the lease
4138    /// cannot tell the difference because the remote is exactly where spar left
4139    /// it.
4140    fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
4141        let head = self.head_oid_checked(cwd)?;
4142        if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
4143            return Ok(());
4144        }
4145        bail!(
4146            "refusing to push {head} because it does not contain the reviewed head {expected}. \
4147             The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
4148             the intended commits on top of it before resuming.",
4149            cwd.display()
4150        )
4151    }
4152
4153    /// Create one remote branch for a split without ever moving an existing ref.
4154    ///
4155    /// `worktree_for_split` chooses a name that is free locally and on origin,
4156    /// but another writer can still take it before the push. An empty expected
4157    /// value in the lease makes this an atomic create: it creates an absent ref,
4158    /// accepts an identical ref as a no-op, and never moves an existing ref.
4159    /// The shared `push` method cannot be used because its lease permits
4160    /// updating a ref fetched earlier.
4161    pub fn push_split_branch(
4162        &self,
4163        cwd: &Path,
4164        branch: &str,
4165    ) -> std::result::Result<(), SplitPushError> {
4166        let remote_ref = format!("refs/heads/{branch}");
4167        let lease = format!("--force-with-lease={remote_ref}:");
4168        let refspec = format!("HEAD:{remote_ref}");
4169        let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
4170            Ok(_) => Ok(()),
4171            Err(push_error) => {
4172                let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
4173                let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
4174                reconcile_failed_split_push(branch, push_error, local, remote)
4175            }
4176        };
4177        self.record_write(result)
4178    }
4179
4180    // -- gh ---------------------------------------------------------------
4181
4182    pub fn gh(&self, args: &[&str]) -> Result<String> {
4183        self.gh_at(None, args)
4184    }
4185
4186    pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
4187        let mut argv = vec!["gh".to_string()];
4188        argv.extend(args.iter().map(|s| s.to_string()));
4189        proc::run(
4190            &argv,
4191            &ExecOpts::new()
4192                .cwd(cwd.unwrap_or(&self.root))
4193                .timeout_secs(300),
4194        )
4195    }
4196
4197    /// Run gh with something on its stdin.
4198    ///
4199    /// A tracker body is far too long to pass on argv, and `--body-file -` is
4200    /// how gh takes one. `proc::exec` already wires the pipe, so this is a
4201    /// sibling of `gh_at` rather than anything new.
4202    pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
4203        let mut argv = vec!["gh".to_string()];
4204        argv.extend(args.iter().map(|s| s.to_string()));
4205        proc::run(
4206            &argv,
4207            &ExecOpts::new()
4208                .cwd(&self.root)
4209                .timeout_secs(300)
4210                .stdin(stdin),
4211        )
4212    }
4213
4214    pub fn gh_try(&self, args: &[&str]) -> String {
4215        let mut argv = vec!["gh".to_string()];
4216        argv.extend(args.iter().map(|s| s.to_string()));
4217        proc::run(
4218            &argv,
4219            &ExecOpts::new()
4220                .cwd(&self.root)
4221                .check(false)
4222                .timeout_secs(300),
4223        )
4224        .unwrap_or_default()
4225    }
4226
4227    /// `gh api -i`: the status line and headers with the body, and the exit
4228    /// code ignored on purpose. A `304 Not Modified`, which is the answer a
4229    /// conditional poll wants, makes gh exit 1 while printing exactly what is
4230    /// needed. Only a spawn failure or a timeout is an error here.
4231    pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
4232        let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
4233        for (name, value) in headers {
4234            argv.push("-H".to_string());
4235            argv.push(format!("{name}: {value}"));
4236        }
4237        argv.push(endpoint.to_string());
4238        let out = proc::exec(
4239            &argv,
4240            &ExecOpts::new()
4241                .cwd(&self.root)
4242                .check(false)
4243                .timeout_secs(300),
4244        )?;
4245        parse_api_response(&out.stdout)
4246            .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
4247    }
4248
4249    /// Every open pull request, with what a sweep needs to know about each.
4250    pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
4251        let text = self.gh(&[
4252            "pr",
4253            "list",
4254            "--state",
4255            "open",
4256            "--limit",
4257            "500",
4258            "--json",
4259            PR_SWEEP_FIELDS,
4260        ])?;
4261        serde_json::from_str(&text)
4262            .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
4263    }
4264
4265    /// One pull request, open or not, with what a sweep needs to know.
4266    pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
4267        let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
4268        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4269    }
4270
4271    /// Whether `login` is in a team, named `org/slug` as gh gives a team in
4272    /// `reviewRequests`. GitHub answers 404 for a team the login is not in, and
4273    /// a team the viewer is in is one it can always see. None when GitHub did
4274    /// not answer either way.
4275    pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
4276        // A team asked for a review belongs to the repository's own owner.
4277        let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
4278        if slug.is_empty() || login.is_empty() {
4279            return None;
4280        }
4281        let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
4282        let resp = self.gh_api_raw(&path, &[]).ok()?;
4283        match resp.status {
4284            200 => {
4285                let body: Value = serde_json::from_str(&resp.body).ok()?;
4286                Some(body.get("state")?.as_str()? == "active")
4287            }
4288            404 => Some(false),
4289            _ => None,
4290        }
4291    }
4292
4293    /// Every review request made on a pull request, oldest first. None when
4294    /// GitHub did not answer.
4295    ///
4296    /// A review spar posts as a comment leaves the request it answered open,
4297    /// so `reviewRequests` alone cannot tell that request from one made after
4298    /// the review. When it was made can.
4299    pub fn review_requests_made(&self, number: i64) -> Option<Vec<crate::model::ReviewRequested>> {
4300        let text = self
4301            .gh(&[
4302                "api",
4303                "graphql",
4304                "-F",
4305                "owner={owner}",
4306                "-F",
4307                "repo={repo}",
4308                "-F",
4309                &format!("number={number}"),
4310                "-f",
4311                &format!("query={REQUESTS_QUERY}"),
4312            ])
4313            .ok()?;
4314        parse_review_requests(&text)
4315    }
4316
4317    /// How many commits lie between two SHAs, when GitHub will say.
4318    pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
4319        if from.is_empty() || to.is_empty() {
4320            return None;
4321        }
4322        let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
4323        self.gh_try(&["api", &path, "--jq", ".total_commits"])
4324            .trim()
4325            .parse()
4326            .ok()
4327    }
4328
4329    /// `owner/name`, for the line that says what is being watched.
4330    pub fn repo_name(&self) -> Option<String> {
4331        let name = self.gh_try(&[
4332            "repo",
4333            "view",
4334            "--json",
4335            "nameWithOwner",
4336            "--jq",
4337            ".nameWithOwner",
4338        ]);
4339        let name = name.trim();
4340        (!name.is_empty()).then(|| name.to_string())
4341    }
4342
4343    pub fn listen_state_path(&self) -> PathBuf {
4344        self.root.join(STATE_DIR).join("state").join("listen.json")
4345    }
4346
4347    pub fn listen_lock_path(&self) -> PathBuf {
4348        self.root.join(STATE_DIR).join("state").join("listen.lock")
4349    }
4350
4351    /// The login `gh` is authenticated as.
4352    ///
4353    /// A hard error, never a degradation. Everything spar wrote has to be
4354    /// excluded from what it answers, and custody cannot be read from git
4355    /// authorship, so this is the only thing that tells spar's own comments
4356    /// from somebody else's. Without it the failure is not "answers a bit too
4357    /// much", it is a thread where spar answers itself until somebody notices.
4358    ///
4359    /// Not cached on disk: `gh auth switch` between runs would make a stored
4360    /// answer wrong in exactly the way that produces that thread.
4361    pub fn viewer_login(&self) -> Result<&str> {
4362        if let Some(login) = self.viewer.get() {
4363            return Ok(login);
4364        }
4365        let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
4366        let login = if !rest.trim().is_empty() {
4367            rest.trim().to_string()
4368        } else {
4369            // A token that cannot read /user can still answer for itself in
4370            // GraphQL, which is the case on some Enterprise installs.
4371            self.gh(&[
4372                "api",
4373                "graphql",
4374                "-f",
4375                "query={ viewer { login } }",
4376                "--jq",
4377                ".data.viewer.login",
4378            ])
4379            .map_err(|e| {
4380                spar_err!(
4381                    "could not find out who `gh` is authenticated as, so spar cannot tell its \
4382                     own comments from anybody else's. {}\nRun `gh auth status`.",
4383                    e.last_line()
4384                )
4385            })?
4386            .trim()
4387            .to_string()
4388        };
4389        if login.is_empty() {
4390            bail!("`gh` reported an empty login. Run `gh auth status`.");
4391        }
4392        Ok(self.viewer.get_or_init(|| login))
4393    }
4394
4395    /// One issue as it stands, open or closed.
4396    ///
4397    /// `fetch_issues` reads a queue to work: it drops a closed issue and fails
4398    /// when nothing survives. Both are wrong for reading one issue back, where
4399    /// closed is an answer and the empty case cannot arise.
4400    pub fn read_issue(&self, number: i64) -> Result<Issue> {
4401        let text = self
4402            .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4403            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4404        serde_json::from_str(&text)
4405            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
4406    }
4407
4408    pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
4409        let mut issues = Vec::new();
4410        for number in numbers {
4411            let text = self
4412                .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4413                .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4414            let issue: Issue = serde_json::from_str(&text)
4415                .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4416            if issue.is_closed() {
4417                crate::log!("issue #{number} is closed, skipping");
4418                continue;
4419            }
4420            issues.push(issue);
4421        }
4422        if issues.is_empty() {
4423            bail!("no open issues to work on");
4424        }
4425        Ok(issues)
4426    }
4427
4428    /// Open items, lowest numbered first, from `min_number` upward.
4429    ///
4430    /// The floor exists because a long lived repository accumulates a tail of
4431    /// old issues nobody is going to get to, and taking the lowest numbered
4432    /// open items means walking straight into them.
4433    fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4434        #[derive(Deserialize)]
4435        struct Row {
4436            number: i64,
4437        }
4438        let text = self.gh(&[
4439            kind,
4440            "list",
4441            "--state",
4442            "open",
4443            "--limit",
4444            &FETCH_CEILING.to_string(),
4445            "--json",
4446            "number",
4447        ])?;
4448        let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4449        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
4450        numbers.sort_unstable();
4451
4452        let noun = if kind == "issue" { "issues" } else { "PRs" };
4453        let found = numbers.len();
4454        if min_number > 0 {
4455            numbers.retain(|n| *n >= min_number);
4456            let skipped = found - numbers.len();
4457            if skipped > 0 {
4458                crate::log!("{skipped} open {noun} below #{min_number} skipped");
4459            }
4460        }
4461        if found >= FETCH_CEILING {
4462            crate::log!(
4463                "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
4464                 considered."
4465            );
4466        }
4467        if numbers.len() > limit {
4468            crate::log!(
4469                "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
4470                 explicitly.",
4471                numbers.len()
4472            );
4473            numbers.truncate(limit);
4474        }
4475        Ok(numbers)
4476    }
4477
4478    /// Open issues, lowest numbered first. `gh issue list` excludes PRs.
4479    pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4480        self.open_numbers("issue", limit, min_number)
4481    }
4482
4483    pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4484        self.open_numbers("pr", limit, min_number)
4485    }
4486
4487    pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
4488        self.branch_prs(branch, "open").into_iter().next()
4489    }
4490
4491    /// The open pull request for a branch, preserving a failed lookup as an
4492    /// error when the caller is deciding whether a write already landed.
4493    pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
4494        let text = self.gh(&[
4495            "pr",
4496            "list",
4497            "--head",
4498            branch,
4499            "--base",
4500            base,
4501            "--state",
4502            "open",
4503            "--json",
4504            "number,url,title,baseRefName",
4505        ])?;
4506        pr_for_base(&text, branch, base)
4507    }
4508
4509    /// Every pull request opened from this branch, merged and closed ones
4510    /// included, because a commit is preserved by whichever one carries it and
4511    /// that is rarely the newest.
4512    fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
4513        self.branch_prs(branch, "all")
4514    }
4515
4516    fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
4517        let text = self.gh_try(&[
4518            "pr",
4519            "list",
4520            "--head",
4521            branch,
4522            "--state",
4523            state,
4524            "--json",
4525            "number,url,title",
4526        ]);
4527        serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
4528    }
4529
4530    /// Whether a number names an issue or a pull request.
4531    ///
4532    /// `gh issue view` happily returns a pull request when handed its number,
4533    /// so it cannot be used to tell them apart. The issues API carries both and
4534    /// marks a pull request with a `pull_request` key, which is definitive.
4535    pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
4536        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
4537        let text = self
4538            .gh(&[
4539                "api",
4540                &path,
4541                "--jq",
4542                "if .pull_request then \"pr\" else \"issue\" end",
4543            ])
4544            .map_err(|e| {
4545                spar_err!(
4546                    "no issue or pull request #{number} in this repository. {}",
4547                    e.last_line()
4548                )
4549            })?;
4550        match text.trim() {
4551            "pr" => Ok(ItemKind::Pr),
4552            "issue" => Ok(ItemKind::Issue),
4553            other => Err(spar_err!(
4554                "could not tell whether #{number} is an issue or a pull request (got {other:?})"
4555            )),
4556        }
4557    }
4558
4559    /// An open pull request that would close this issue, whoever opened it.
4560    ///
4561    /// spar's own branch naming is checked first because it is exact and cheap.
4562    /// Falling back to GitHub's own issue linkage is what lets spar pick up a
4563    /// pull request a person started on a branch named anything at all.
4564    ///
4565    /// An error rather than `None` when the question could not be asked. The
4566    /// two answers are not interchangeable: "no pull request exists" is the one
4567    /// that makes spar implement over the top of somebody's work and open a
4568    /// second pull request for the same issue.
4569    pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
4570        let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
4571        if let Some(pr) = mine {
4572            return Ok(Some(pr));
4573        }
4574        let listed = match self.gh(&[
4575            "pr",
4576            "list",
4577            "--state",
4578            "open",
4579            "--limit",
4580            &FETCH_CEILING.to_string(),
4581            "--json",
4582            "number,url,title,baseRefName,closingIssuesReferences",
4583        ]) {
4584            Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
4585                spar_err!("could not read the pull request list while looking for #{issue}")
4586            })?,
4587            Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
4588        };
4589        if listed.len() >= FETCH_CEILING {
4590            logwarn!(
4591                "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a                  pull request past it cannot be seen from here."
4592            );
4593        }
4594        let default = self.default_branch(base);
4595        if !base.eq_ignore_ascii_case(&default) {
4596            // GitHub populates closingIssuesReferences only for pull requests
4597            // targeting the default branch, so on any other base the linkage is
4598            // silence rather than an answer.
4599            logwarn!(
4600                "#{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."
4601            );
4602        }
4603        Ok(pick_linked_pr(&listed, issue))
4604    }
4605
4606    /// spar's own branch, with a failed lookup kept as a failure.
4607    fn pr_for_branch_failing_closed(
4608        &self,
4609        branch: &str,
4610        base: &str,
4611        issue: i64,
4612    ) -> Result<Option<PrRef>> {
4613        match self.try_pr_for_branch(branch, base) {
4614            Ok(found) => Ok(found),
4615            Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
4616        }
4617    }
4618
4619    /// What to do when GitHub would not say whether a pull request exists.
4620    ///
4621    /// An error, because "no pull request exists" is the answer that makes spar
4622    /// implement from scratch and open a second pull request over somebody's
4623    /// work. The one exception is a repository gh cannot reach at all: there is
4624    /// no pull request to collide with when there is no GitHub repository in
4625    /// play, and every later write would fail anyway. That case is a warning and
4626    /// a `None`, so a run against a plain git remote behaves as it always did.
4627    fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
4628        if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
4629            return Err(spar_err!(
4630                "could not check whether a pull request already exists for #{issue}: {}. \
4631                 Refusing to implement over the top of work that may already be open.",
4632                e.last_line()
4633            ));
4634        }
4635        logwarn!(
4636            "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
4637             whether a pull request already exists for it.",
4638            e.last_line()
4639        );
4640        Ok(())
4641    }
4642
4643    pub fn pr_view(&self, number: i64) -> Result<PrView> {
4644        let text = self.gh(&[
4645            "pr",
4646            "view",
4647            &number.to_string(),
4648            "--json",
4649            "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,\
4650             isCrossRepository,headRepositoryOwner,headRepository",
4651        ])?;
4652        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4653    }
4654
4655    fn try_pr_state(&self, number: i64) -> Result<String> {
4656        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
4657        serde_json::from_str::<Value>(text.trim())
4658            .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
4659            .get("state")
4660            .and_then(Value::as_str)
4661            .map(str::to_string)
4662            .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
4663    }
4664
4665    pub fn pr_state(&self, number: i64) -> String {
4666        self.try_pr_state(number).unwrap_or_default()
4667    }
4668
4669    /// The commit currently exposed as a pull request's head.
4670    pub fn pr_head_oid(&self, number: i64) -> Result<String> {
4671        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
4672        let oid = serde_json::from_str::<Value>(&text)
4673            .ok()
4674            .and_then(|value| {
4675                value
4676                    .get("headRefOid")
4677                    .and_then(Value::as_str)
4678                    .map(str::trim)
4679                    .filter(|oid| !oid.is_empty())
4680                    .map(str::to_string)
4681            })
4682            .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
4683        Ok(oid)
4684    }
4685
4686    /// Where a pull request's head stands now: what GitHub's pull request
4687    /// names, and, for a branch in this repository, where `origin` has the
4688    /// branch. The two differ until GitHub catches up with a push.
4689    pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
4690        let pull = self.pr_head_oid(pr.number)?;
4691        let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
4692            None
4693        } else {
4694            let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
4695            let listed = self
4696                .git(&["ls-remote", "--heads", "origin", &remote_ref])
4697                .map_err(|e| {
4698                    spar_err!(
4699                        "could not read where {} is on origin: {}",
4700                        pr.head_ref_name.trim(),
4701                        e.last_line()
4702                    )
4703                })?;
4704            remote_head_oid(&listed, &remote_ref)?
4705        };
4706        Ok(LiveHead { pull, branch })
4707    }
4708
4709    /// Open a pull request from `branch`, published at `target`.
4710    ///
4711    /// The lookup that confirms it landed goes by branch name alone, which
4712    /// `gh pr list --head` matches on a fork's pull request too.
4713    pub fn create_pr(
4714        &self,
4715        cwd: &Path,
4716        branch: &str,
4717        target: &PushTarget,
4718        base: &str,
4719        title: &str,
4720        body: &str,
4721    ) -> Result<PrRef> {
4722        let title = self.record_failed_write(self.clean_title(title))?;
4723        let body = self.record_failed_write(self.clean(body))?;
4724        let head = target.head(branch);
4725        let mut argv = vec![
4726            "pr", "create", "--base", base, "--head", &head, "--title", &title, "--body", &body,
4727        ];
4728        if self.drafts != Drafts::Never {
4729            argv.push("--draft");
4730        }
4731        let created = self.gh_at(Some(cwd), &argv);
4732        let found = self.try_pr_for_branch(branch, base);
4733        self.record_write(reconcile_pr_creation(branch, created, found))
4734    }
4735
4736    pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
4737        let body = signed(&self.record_failed_write(self.clean(body))?);
4738        let comments = self.record_failed_write(self.try_issue_comments(number))?;
4739        if has_exact_comment(&comments, &body) {
4740            return Ok(());
4741        }
4742        let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
4743        let result = match posted {
4744            Ok(_) => Ok(()),
4745            Err(post_error) => {
4746                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
4747            }
4748        };
4749        self.record_write(result)
4750    }
4751
4752    /// Whether the viewer wrote this pull request. GitHub refuses to let an
4753    /// author approve or request changes on their own, so a verdict on one can
4754    /// only ever be a comment.
4755    fn pr_is_own(&self, number: i64) -> Result<bool> {
4756        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
4757        let author = serde_json::from_str::<Value>(&text)
4758            .ok()
4759            .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
4760            .unwrap_or_default();
4761        let viewer = self.viewer_login()?.to_string();
4762        Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
4763    }
4764
4765    /// Submit a formal review, or a comment carrying the verdict's words when
4766    /// GitHub will not take the verdict: the viewer's own pull request, or no
4767    /// verdict at all.
4768    ///
4769    /// Mirrors `comment_pr` step for step: the style gate, the signature, a
4770    /// duplicate check against the viewer's own reviews, the write, and a read
4771    /// back on failure so an unverifiable write is reported as one.
4772    ///
4773    /// `commit` is the commit the reviewers read. GitHub attaches a review
4774    /// that names no commit to whatever the head is when it arrives, so a push
4775    /// made while a review ran would otherwise receive an approval nobody
4776    /// gave it. `gh pr review` cannot name one, hence the REST call.
4777    pub fn submit_review(
4778        &self,
4779        number: i64,
4780        verdict: Option<crate::model::ReviewVerdict>,
4781        body: &str,
4782        commit: &str,
4783    ) -> Result<()> {
4784        let own = match verdict {
4785            Some(_) => self.record_failed_write(self.pr_is_own(number))?,
4786            None => false,
4787        };
4788        let text = match (verdict, own) {
4789            (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
4790            _ => body.to_string(),
4791        };
4792        let body = signed(&self.record_failed_write(self.clean(&text))?);
4793        let viewer = self.record_failed_write(self.viewer_login())?.to_string();
4794        let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
4795        if has_viewer_review(&reviews, &viewer, &body, commit) {
4796            return Ok(());
4797        }
4798        let event = match (verdict, own) {
4799            (Some(v), false) => v.api_event(),
4800            _ => "COMMENT",
4801        };
4802        let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
4803        let posted = self.gh(&[
4804            "api",
4805            "-X",
4806            "POST",
4807            &path,
4808            "-f",
4809            &format!("commit_id={commit}"),
4810            "-f",
4811            &format!("event={event}"),
4812            "-f",
4813            &format!("body={body}"),
4814            "--silent",
4815        ]);
4816        let result = match posted {
4817            Ok(_) => Ok(()),
4818            Err(post_error) => reconcile_review_post(
4819                number,
4820                &body,
4821                &viewer,
4822                commit,
4823                post_error,
4824                self.try_pr_reviews(number),
4825            ),
4826        };
4827        self.record_write(result)
4828    }
4829
4830    pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
4831        let body = signed(&self.record_failed_write(self.clean(body))?);
4832        let comments = self.record_failed_write(self.try_issue_comments(number))?;
4833        if has_exact_comment(&comments, &body) {
4834            return Ok(());
4835        }
4836        let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
4837        let result = match posted {
4838            Ok(_) => Ok(()),
4839            Err(post_error) => {
4840                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
4841            }
4842        };
4843        self.record_write(result)
4844    }
4845
4846    /// Comment, then close as not planned.
4847    ///
4848    /// Only ever called when both agents independently declined the issue: one
4849    /// agent's opinion is not enough to close somebody's report.
4850    pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
4851        self.comment_issue(number, body)?;
4852        let n = number.to_string();
4853        let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
4854            Ok(_) => Ok(()),
4855            // Older gh builds do not take --reason.
4856            Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
4857                spar_err!(
4858                    "commented on #{number} but could not close it: {}",
4859                    e.last_line()
4860                )
4861            }),
4862        };
4863        self.record_write(closed)
4864    }
4865
4866    /// Replace an issue body, refusing unless it is still byte for byte what
4867    /// the caller read and validating only the fragment spar inserted.
4868    ///
4869    /// The only place spar rewrites text somebody else wrote, so the check is
4870    /// the whole point: an edit computed from a body that has since moved would
4871    /// silently delete whatever moved it. The caller decides whether another
4872    /// attempt is safe for its workflow.
4873    ///
4874    /// Deliberately not through `clean_issue_body`. The body is mostly a
4875    /// person's own prose, and the scrub would rewrite their punctuation while
4876    /// the length budget could truncate the end of a long report. `inserted` is
4877    /// the only text here spar is answerable for, so it still passes through the
4878    /// style gate. The full body travels over stdin because a tracker can be far
4879    /// too long for one argument.
4880    pub fn edit_issue_body(
4881        &self,
4882        number: i64,
4883        expected: &str,
4884        body: &str,
4885        inserted: &str,
4886    ) -> Result<()> {
4887        let cleaned = self.record_failed_write(self.clean(inserted))?;
4888        if cleaned.trim() != inserted.trim() {
4889            return self.record_failed_write(Err(spar_err!(
4890                "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
4891            )));
4892        }
4893        let current = self.record_failed_write(self.issue_body(number))?;
4894        if current != expected {
4895            return self.record_failed_write(Err(spar_err!(
4896                "the body of #{number} changed since it was read, so it was left alone rather \
4897                 than written over."
4898            )));
4899        }
4900        let edited = self.gh_stdin(
4901            &["issue", "edit", &number.to_string(), "--body-file", "-"],
4902            body,
4903        );
4904        let result = match edited {
4905            Ok(_) => Ok(()),
4906            Err(edit_error) => {
4907                reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
4908            }
4909        };
4910        self.record_write(result)
4911    }
4912
4913    /// One issue's body, exactly as GitHub holds it.
4914    pub fn issue_body(&self, number: i64) -> Result<String> {
4915        #[derive(Deserialize)]
4916        struct Row {
4917            #[serde(default)]
4918            body: Option<String>,
4919        }
4920        let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
4921        let row: Row = serde_json::from_str(text.trim())
4922            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4923        Ok(row.body.unwrap_or_default())
4924    }
4925
4926    /// Every open issue with its title and body, in one call.
4927    ///
4928    /// For a screen that has to say something about each of twenty items before
4929    /// anything expensive happens. One call rather than one per issue.
4930    pub fn open_issue_rows(&self) -> Vec<Issue> {
4931        let text = self.gh_try(&[
4932            "issue",
4933            "list",
4934            "--state",
4935            "open",
4936            "--limit",
4937            &FETCH_CEILING.to_string(),
4938            "--json",
4939            "number,title,body,labels,state,stateReason,url",
4940        ]);
4941        serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
4942    }
4943
4944    /// Every open pull request with its size, in one call.
4945    pub fn open_pr_rows(&self) -> Vec<PrRow> {
4946        let text = self.gh_try(&[
4947            "pr",
4948            "list",
4949            "--state",
4950            "open",
4951            "--limit",
4952            &FETCH_CEILING.to_string(),
4953            "--json",
4954            "number,title,changedFiles,additions,deletions",
4955        ]);
4956        serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
4957    }
4958
4959    pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
4960        self.create_issue_apart_from(title, body, None)
4961    }
4962
4963    pub fn create_issue_apart_from(
4964        &self,
4965        title: &str,
4966        body: &str,
4967        apart_from: Option<i64>,
4968    ) -> Result<String> {
4969        let title = self.record_failed_write(self.clean_title(title))?;
4970        let body = self.record_failed_write(self.clean_issue_body(body))?;
4971        let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
4972        let result = match created {
4973            Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
4974            created => {
4975                let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
4976                reconcile_issue_creation(&title, created, found)
4977            }
4978        };
4979        self.record_write(result)
4980    }
4981}
4982
4983/// An issue that already covers what spar was about to file.
4984#[derive(Debug, Clone)]
4985pub struct ExistingIssue {
4986    pub number: i64,
4987    pub url: String,
4988    pub title: String,
4989    pub body: String,
4990    pub open: bool,
4991}
4992
4993impl Repo {
4994    pub(crate) fn try_exact_issue_apart_from(
4995        &self,
4996        title: &str,
4997        body: &str,
4998        apart_from: Option<i64>,
4999    ) -> Result<Option<ExistingIssue>> {
5000        #[derive(Deserialize)]
5001        #[serde(rename_all = "camelCase")]
5002        struct Row {
5003            number: i64,
5004            #[serde(default)]
5005            title: String,
5006            #[serde(default)]
5007            url: String,
5008            #[serde(default)]
5009            body: Option<String>,
5010            #[serde(default)]
5011            state: String,
5012        }
5013
5014        let text = self.gh(&[
5015            "issue",
5016            "list",
5017            "--state",
5018            "all",
5019            "--limit",
5020            "100",
5021            "--json",
5022            "number,title,url,body,state,stateReason",
5023        ])?;
5024        let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5025            .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
5026        Ok(rows
5027            .into_iter()
5028            .filter(|row| Some(row.number) != apart_from)
5029            .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
5030            .map(|row| ExistingIssue {
5031                number: row.number,
5032                url: row.url,
5033                title: row.title,
5034                body: row.body.unwrap_or_default(),
5035                open: row.state.eq_ignore_ascii_case("open"),
5036            }))
5037    }
5038
5039    /// An issue that already describes this defect, however it was worded.
5040    ///
5041    /// Exact title matching let duplicates through: two agents, or two runs a
5042    /// week apart, never word one defect identically. A real run filed two
5043    /// duplicates that way, and each had to be closed by hand afterwards.
5044    /// Titles alone are too thin to match on, so this compares titles and
5045    /// bodies together.
5046    pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
5047        self.find_similar_issue_apart_from(title, body, None)
5048    }
5049
5050    /// The same search, with one issue that cannot be its own duplicate.
5051    ///
5052    /// A tracker's body quotes every item in its checklist, so searching for an
5053    /// item's words matches the tracker before it matches anything else. That
5054    /// would link an item to the issue it is written in.
5055    /// The search a tracker runs, which wants closed issues too: a checklist
5056    /// item matching a closed issue is a done item that wants a link.
5057    pub fn find_similar_issue_apart_from(
5058        &self,
5059        title: &str,
5060        body: &str,
5061        apart_from: Option<i64>,
5062    ) -> Option<ExistingIssue> {
5063        self.try_similar_issue(title, body, apart_from, true)
5064            .ok()
5065            .flatten()
5066    }
5067
5068    /// The search a filing runs, over open issues only.
5069    pub fn try_find_similar_issue_apart_from(
5070        &self,
5071        title: &str,
5072        body: &str,
5073        apart_from: Option<i64>,
5074    ) -> Result<Option<ExistingIssue>> {
5075        self.try_similar_issue(title, body, apart_from, false)
5076    }
5077
5078    /// The same search, preserving lookup failure for a caller about to write.
5079    fn try_similar_issue(
5080        &self,
5081        title: &str,
5082        body: &str,
5083        apart_from: Option<i64>,
5084        include_closed: bool,
5085    ) -> Result<Option<ExistingIssue>> {
5086        #[derive(Deserialize)]
5087        #[serde(rename_all = "camelCase")]
5088        struct Row {
5089            number: i64,
5090            #[serde(default)]
5091            title: String,
5092            #[serde(default)]
5093            url: String,
5094            #[serde(default)]
5095            body: String,
5096            #[serde(default)]
5097            state: String,
5098        }
5099        if title.trim().is_empty() {
5100            return Ok(None);
5101        }
5102        // Search on the title's own words: GitHub's index is the cheap way to
5103        // narrow the field before comparing properly.
5104        let query: String = title
5105            .chars()
5106            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5107            .take(120)
5108            .collect();
5109        // Open issues only when something is about to be filed. A fuzzy match
5110        // against a closed one is the case worth keeping rather than dropping:
5111        // a defect that was fixed, closed, and has come back is exactly the
5112        // finding a maintainer wants, and "#N covers it and is closed" wrote it
5113        // nowhere. An exact match is still checked against every state, since
5114        // that is a regression of one specific issue rather than a resemblance
5115        // to one, and a tracker still searches closed issues because a
5116        // checklist item matching one is a done item that wants a link.
5117        let state = if include_closed { "all" } else { "open" };
5118        let text = self.gh(&[
5119            "issue",
5120            "list",
5121            "--state",
5122            state,
5123            "--limit",
5124            "100",
5125            "--search",
5126            query.trim(),
5127            "--json",
5128            "number,title,url,body,state,stateReason",
5129        ])?;
5130        let rows: Vec<Row> = serde_json::from_str(text.trim())
5131            .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
5132
5133        Ok(rows
5134            .into_iter()
5135            .filter(|row| Some(row.number) != apart_from)
5136            .find(|row| {
5137                row.title.trim().eq_ignore_ascii_case(title.trim())
5138                    || textsim::covers(title, body, &row.title, &row.body)
5139            })
5140            .map(|row| ExistingIssue {
5141                number: row.number,
5142                url: row.url,
5143                title: row.title,
5144                open: row.state.eq_ignore_ascii_case("open"),
5145                body: row.body,
5146            }))
5147    }
5148
5149    /// Avoid filing a duplicate when a follow-up already exists.
5150    pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
5151        #[derive(Deserialize)]
5152        struct Row {
5153            title: String,
5154            url: String,
5155        }
5156        let needle = title.trim().to_lowercase();
5157        if needle.is_empty() {
5158            return None;
5159        }
5160        // Quotes and newlines would be read as search syntax rather than text.
5161        let query: String = title
5162            .chars()
5163            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5164            .take(120)
5165            .collect();
5166        let text = self.gh_try(&[
5167            "issue",
5168            "list",
5169            "--state",
5170            "all",
5171            "--limit",
5172            "100",
5173            "--search",
5174            query.trim(),
5175            "--json",
5176            "number,title,url",
5177        ]);
5178        serde_json::from_str::<Vec<Row>>(text.trim())
5179            .ok()?
5180            .into_iter()
5181            .find(|row| row.title.trim().to_lowercase() == needle)
5182            .map(|row| row.url)
5183    }
5184
5185    /// Squash merge, tolerating cleanup failures after a successful merge.
5186    ///
5187    /// Take a pull request out of draft, once the review has converged.
5188    ///
5189    /// Best effort for the remaining workflow. A failure does not discard the
5190    /// review or stop later independent work, but the final write summary
5191    /// reports it and the command returns non-zero.
5192    pub fn mark_ready(&self, number: i64) -> bool {
5193        match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
5194            Ok(_) => true,
5195            Err(e) => {
5196                logdim!(
5197                    "PR #{number} is approved but could not be taken out of draft: {}",
5198                    e.last_line()
5199                );
5200                false
5201            }
5202        }
5203    }
5204
5205    /// The worktree that has `branch` checked out, if one does.
5206    fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
5207        let listed = self
5208            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
5209            .ok()?;
5210        let wanted = format!("branch refs/heads/{branch}");
5211        let mut current: Option<PathBuf> = None;
5212        for line in listed.lines() {
5213            if let Some(path) = line.strip_prefix("worktree ") {
5214                current = Some(PathBuf::from(path));
5215            } else if line == wanted {
5216                return current;
5217            }
5218        }
5219        None
5220    }
5221
5222    /// Finish the local cleanup a merge could not.
5223    ///
5224    /// `gh pr merge --delete-branch` gives up on the local branch whenever any
5225    /// worktree has it checked out, which is the ordinary state rather than an
5226    /// odd one: `spar run` opens the pull request from
5227    /// `.spar-worktrees/issue-N` and leaves the checkout there, and the merge
5228    /// usually happens in a later `spar resume` working somewhere else. The
5229    /// next run's sweep tidied it, so nothing was ever lost, but the run that
5230    /// did the merge reported a failure about a branch nobody will touch again
5231    /// and left a checkout for the next run to explain.
5232    ///
5233    /// Held to the same rules the sweep uses, because they are the rules that
5234    /// decide whether anything would be lost: a branch spar recorded creating,
5235    /// a worktree of spar's own with no recoverable work in it, and a tip some
5236    /// surviving ref preserves. Anything else is left exactly where it is.
5237    fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
5238        let branch = self.pr_view(number).ok()?.head_ref_name;
5239        if branch.is_empty() || !self.known_branches().contains_key(&branch) {
5240            return None;
5241        }
5242        let mut released = None;
5243        if let Some(path) = self.worktree_holding(&branch) {
5244            if !path.starts_with(self.root.join(WORKTREE_DIR)) {
5245                return None;
5246            }
5247            if !matches!(
5248                self.has_recoverable_work(&path, Disposable::BuildOutput),
5249                Ok(false)
5250            ) {
5251                return None;
5252            }
5253            match self.remove_worktree_at(&path) {
5254                Ok(true) => released = Some(path),
5255                _ => return None,
5256            }
5257        }
5258        match self.delete_branch_if_safe(&branch) {
5259            Ok(true) => {
5260                self.forget_branch(&branch);
5261                Some(match released {
5262                    Some(path) => format!(
5263                        " The worktree at {} was released and branch {branch} deleted.",
5264                        path.display()
5265                    ),
5266                    None => format!(" Branch {branch} was deleted."),
5267                })
5268            }
5269            _ => released.map(|path| {
5270                format!(
5271                    " The worktree at {} was released, but branch {branch} was kept.",
5272                    path.display()
5273                )
5274            }),
5275        }
5276    }
5277
5278    /// `gh pr merge --delete-branch` exits non-zero when it cannot delete the
5279    /// local branch, which happens *after* the merge has already landed.
5280    /// Treating that as a failure reports work as lost when it is not.
5281    pub fn merge_pr(&self, number: i64) -> Result<()> {
5282        let n = number.to_string();
5283        let merged = match self.gh(&merge_pr_args(&n, None, true)) {
5284            Ok(_) => Ok(()),
5285            Err(e) => {
5286                if self.pr_state(number) == "MERGED" {
5287                    let finished = self
5288                        .finish_merged_branch_cleanup(number)
5289                        .unwrap_or_default();
5290                    logdim!(
5291                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5292                        e.last_line()
5293                    );
5294                    Ok(())
5295                } else {
5296                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5297                }
5298            }
5299        };
5300        self.record_write(merged)
5301    }
5302
5303    /// Squash merge only if the pull request still exposes the reviewed head.
5304    pub fn merge_pr_at_head(
5305        &self,
5306        number: i64,
5307        expected_head: &str,
5308        delete_branch: bool,
5309    ) -> Result<()> {
5310        let n = number.to_string();
5311        let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
5312            Ok(_) => Ok(()),
5313            Err(e) => {
5314                if self.pr_state(number) == "MERGED" {
5315                    let finished = self
5316                        .finish_merged_branch_cleanup(number)
5317                        .unwrap_or_default();
5318                    logdim!(
5319                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5320                        e.last_line()
5321                    );
5322                    Ok(())
5323                } else {
5324                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5325                }
5326            }
5327        };
5328        self.record_write(merged)
5329    }
5330
5331    // -- follow-ups -------------------------------------------------------
5332
5333    /// The queue of follow-ups recorded locally rather than filed, which
5334    /// `spar followup` works.
5335    pub fn followups_path(&self) -> PathBuf {
5336        self.root.join(STATE_DIR).join("followups.md")
5337    }
5338
5339    /// Where `spar brainstorm` writes a session unless told otherwise.
5340    ///
5341    /// Under the state directory, so it is kept out of the repository's own
5342    /// `git status`; `--out` puts one somewhere a person would commit.
5343    pub fn brainstorms_dir(&self) -> PathBuf {
5344        self.root.join(STATE_DIR).join("brainstorms")
5345    }
5346
5347    /// What `spar followup` already dealt with, kept beside the queue.
5348    ///
5349    /// Two jobs. It is what stops `append_local_followup` re-recording a
5350    /// follow-up whose entry has since left the queue, which would otherwise
5351    /// turn the file into a ring buffer of things already filed. And it keeps
5352    /// the text of an entry a screening pass ruled stale, so a wrong verdict
5353    /// costs a re-read rather than the only copy of a real defect.
5354    pub fn worked_followups_path(&self) -> PathBuf {
5355        self.root.join(STATE_DIR).join("followups.done.md")
5356    }
5357
5358    /// What `spar checkin` has already answered on one pull request or issue.
5359    /// Where what a run spent is kept.
5360    pub fn spend_path(&self) -> PathBuf {
5361        self.root.join(STATE_DIR).join("state").join("spend.json")
5362    }
5363
5364    /// Append this run's calls to the record.
5365    ///
5366    /// Appended rather than replaced, so a resumed pull request adds to the
5367    /// count of what it has cost so far rather than starting it again, and so
5368    /// anybody charting this has a file to chart.
5369    pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
5370        if calls.is_empty() {
5371            return Ok(());
5372        }
5373        let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
5374            .ok()
5375            .and_then(|text| serde_json::from_str(&text).ok())
5376            .unwrap_or_default();
5377        all.extend(calls.iter().cloned());
5378        if let Some(parent) = self.spend_path().parent() {
5379            let _ = std::fs::create_dir_all(parent);
5380        }
5381        write_json_atomic(&self.spend_path(), &all)
5382    }
5383
5384    /// Who wrote the first draft of each issue, by number.
5385    ///
5386    /// Custody alternates within a pull request and the record of it lives in
5387    /// that pull request's state. This is the other question, which nothing
5388    /// answered: over a queue, which model authored what.
5389    pub fn implementors_path(&self) -> PathBuf {
5390        self.root
5391            .join(STATE_DIR)
5392            .join("state")
5393            .join("implementors.json")
5394    }
5395
5396    pub fn read_implementors(&self) -> BTreeMap<i64, String> {
5397        std::fs::read_to_string(self.implementors_path())
5398            .ok()
5399            .and_then(|text| serde_json::from_str(&text).ok())
5400            .unwrap_or_default()
5401    }
5402
5403    /// Best effort: this is a record for a person, not something the loop reads
5404    /// back, so a failure to write it is not a reason to stop.
5405    pub fn remember_implementor(&self, issue: i64, agent: &str) {
5406        let mut known = self.read_implementors();
5407        known.insert(issue, agent.to_string());
5408        if let Some(parent) = self.implementors_path().parent() {
5409            let _ = std::fs::create_dir_all(parent);
5410        }
5411        if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
5412            logdim!("could not record who implemented #{issue}: {e}");
5413        }
5414    }
5415
5416    /// Where the issues triage disagreed about are remembered.
5417    ///
5418    /// One file rather than one per issue: it is read once per run, before any
5419    /// triage call, to decide what not to ask about again.
5420    pub fn contested_path(&self) -> PathBuf {
5421        self.root
5422            .join(STATE_DIR)
5423            .join("state")
5424            .join("contested.json")
5425    }
5426
5427    /// The issues a person has been asked to decide, by number.
5428    pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
5429        std::fs::read_to_string(self.contested_path())
5430            .ok()
5431            .and_then(|text| serde_json::from_str(&text).ok())
5432            .unwrap_or_default()
5433    }
5434
5435    /// Remember an issue triage could not agree on.
5436    ///
5437    /// Best effort: a run that cannot write this asks again next time, which is
5438    /// what it did before this existed.
5439    pub fn remember_contested(&self, items: &[ContestedItem]) {
5440        if items.is_empty() {
5441            return;
5442        }
5443        let mut known = self.read_contested();
5444        for item in items {
5445            known.insert(
5446                item.issue,
5447                Contested {
5448                    title: item.title.clone(),
5449                    positions: item.positions.clone(),
5450                    reasons: item.reasons.clone(),
5451                },
5452            );
5453        }
5454        if let Some(parent) = self.contested_path().parent() {
5455            let _ = std::fs::create_dir_all(parent);
5456        }
5457        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5458            logdim!("could not record the contested issues: {e}");
5459        }
5460    }
5461
5462    /// Ask about them again, because a person has acted on them.
5463    pub fn forget_contested(&self, numbers: &[i64]) {
5464        let mut known = self.read_contested();
5465        let before = known.len();
5466        known.retain(|number, _| !numbers.contains(number));
5467        if known.len() == before {
5468            return;
5469        }
5470        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5471            logdim!("could not update the contested issues: {e}");
5472        }
5473    }
5474
5475    pub fn checkin_state_path(&self, number: i64) -> PathBuf {
5476        self.root
5477            .join(STATE_DIR)
5478            .join("state")
5479            .join(format!("checkin-{number}.json"))
5480    }
5481
5482    /// Where `spar review` records what it reviewed, and a second look reads it.
5483    pub fn review_record_path(&self, number: i64) -> PathBuf {
5484        self.root
5485            .join(STATE_DIR)
5486            .join("state")
5487            .join(format!("review-{number}.json"))
5488    }
5489
5490    /// None when there is no record, or when it cannot be read. A record spar
5491    /// cannot read is one it reviews afresh over, and the log says so.
5492    pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
5493        let path = self.review_record_path(number);
5494        let text = std::fs::read_to_string(&path).ok()?;
5495        match serde_json::from_str(&text) {
5496            Ok(record) => Some(record),
5497            Err(e) => {
5498                logdim!("could not read {}: {e}", path.display());
5499                None
5500            }
5501        }
5502    }
5503
5504    pub fn write_review_record(
5505        &self,
5506        number: i64,
5507        record: &crate::model::ReviewRecord,
5508    ) -> Result<()> {
5509        write_json_atomic(&self.review_record_path(number), record)
5510    }
5511
5512    /// Append a follow-up to a local note instead of the tracker.
5513    ///
5514    /// Deduplicated on the title, matching the issue path. The body arrives
5515    /// with its provenance already stamped by the caller, so nothing is added
5516    /// here.
5517    ///
5518    /// A write that did not happen is reported as such rather than as a
5519    /// duplicate: the caller settles the point on the strength of this answer,
5520    /// and settling it on a failed write is how a real defect is lost.
5521    ///
5522    /// Both files are checked, because `spar followup` removes an entry from
5523    /// the queue once it has filed it. Checking only the queue would let the
5524    /// next run that rediscovers the same defect append it again, on top of the
5525    /// issue that now exists for it.
5526    pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
5527        let path = self.followups_path();
5528        let heading = format!("## {}", title.trim());
5529        for seen in [&path, &self.worked_followups_path()] {
5530            if let Ok(existing) = std::fs::read_to_string(seen) {
5531                if has_heading(&existing, title) {
5532                    logdim!("follow-up already noted: {title}");
5533                    return Followup::Covered(format!("note: {}", title.trim()));
5534                }
5535            }
5536        }
5537        if let Some(parent) = path.parent() {
5538            let _ = std::fs::create_dir_all(parent);
5539        }
5540        use std::io::Write;
5541        // The caller already stamped the provenance into the body. Adding
5542        // "From #N." here as well printed it twice, in two different wordings.
5543        //
5544        // The marker above the heading is what makes the entry boundary
5545        // unambiguous to the parser, since the body's own sections are written
5546        // at the same heading level as the title.
5547        let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
5548        match std::fs::OpenOptions::new()
5549            .create(true)
5550            .append(true)
5551            .open(&path)
5552        {
5553            Ok(mut file) => match file.write_all(entry.as_bytes()) {
5554                Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
5555                Err(e) => {
5556                    logdim!("could not write {}: {e}", path.display());
5557                    Followup::Failed
5558                }
5559            },
5560            Err(e) => {
5561                logdim!("could not write {}: {e}", path.display());
5562                Followup::Failed
5563            }
5564        }
5565    }
5566
5567    /// Record what `spar followup` did with an entry, and why.
5568    ///
5569    /// Best effort: an archive that could not be written is not a reason to
5570    /// stop, since the entry has already been filed or ruled on.
5571    pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
5572        let path = self.worked_followups_path();
5573        if let Some(parent) = path.parent() {
5574            let _ = std::fs::create_dir_all(parent);
5575        }
5576        use std::io::Write;
5577        let entry = format!(
5578            "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
5579            title.trim(),
5580            body.trim()
5581        );
5582        if let Ok(mut file) = std::fs::OpenOptions::new()
5583            .create(true)
5584            .append(true)
5585            .open(&path)
5586        {
5587            let _ = file.write_all(entry.as_bytes());
5588        }
5589    }
5590
5591    // -- resumable state --------------------------------------------------
5592    //
5593    // Custody cannot be read from GitHub authorship: every agent commits and
5594    // comments as the same git identity, so `author` is always the human who
5595    // ran spar. State is kept on disk by default and can additionally travel in
5596    // a PR comment, which is what lets a run be resumed from another machine.
5597
5598    /// Where a comment spar produced but did not post is kept.
5599    pub fn pending_comment_path(&self, number: i64) -> PathBuf {
5600        self.root
5601            .join(STATE_DIR)
5602            .join("reviews")
5603            .join(format!("pr-{number}.md"))
5604    }
5605
5606    /// Keep a comment spar decided not to post.
5607    ///
5608    /// A dry run that prints and forgets means agreeing with what you read
5609    /// costs a second full review. Saving it makes the whole point of reading
5610    /// it first: look, edit if you like, then post what you already paid for.
5611    pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
5612        let path = self.pending_comment_path(number);
5613        if let Some(parent) = path.parent() {
5614            std::fs::create_dir_all(parent)
5615                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
5616        }
5617        std::fs::write(&path, text)
5618            .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5619        Ok(path)
5620    }
5621
5622    pub fn read_pending_comment(&self, number: i64) -> Option<String> {
5623        std::fs::read_to_string(self.pending_comment_path(number)).ok()
5624    }
5625
5626    pub fn state_path(&self, number: i64) -> PathBuf {
5627        self.root
5628            .join(STATE_DIR)
5629            .join("state")
5630            .join(format!("pr-{number}.json"))
5631    }
5632
5633    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
5634        let path = self.state_path(number);
5635        let text = std::fs::read_to_string(&path).ok()?;
5636        match serde_json::from_str(&text) {
5637            Ok(state) => Some(state),
5638            Err(_) => {
5639                logdim!("could not read {}, starting fresh", path.display());
5640                None
5641            }
5642        }
5643    }
5644
5645    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
5646        if let Some(local) = self.read_local_state(pr.number) {
5647            return Some(local);
5648        }
5649        if self.state_store.writes_pr() {
5650            return self.read_pr_state(pr.number);
5651        }
5652        None
5653    }
5654
5655    pub(crate) fn read_state_for_head(
5656        &self,
5657        pr: &PrView,
5658        actual_head: &str,
5659    ) -> Option<PersistedState> {
5660        let local = self
5661            .state_store
5662            .writes_local()
5663            .then(|| self.read_local_state(pr.number))
5664            .flatten();
5665        let remote = self
5666            .state_store
5667            .writes_pr()
5668            .then(|| self.read_pr_state(pr.number))
5669            .flatten();
5670        let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
5671        if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
5672            self.remember_checkpoint(pr.number, checkpoint);
5673        }
5674        choose_state_for_head(candidates, actual_head)
5675    }
5676
5677    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
5678        self.try_read_pr_state(number).ok().flatten()
5679    }
5680
5681    fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
5682        for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
5683            if let Some(state) = parse_state_comment(&body) {
5684                return Ok(Some(state));
5685            }
5686        }
5687        Ok(None)
5688    }
5689
5690    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5691        let remote_state = if self.state_store.writes_pr() {
5692            self.try_read_pr_state(number)
5693        } else {
5694            Ok(None)
5695        };
5696        self.write_state_after_remote_read(number, state, remote_state)
5697    }
5698
5699    fn write_state_after_remote_read(
5700        &self,
5701        number: i64,
5702        state: &PersistedState,
5703        remote_state: Result<Option<PersistedState>>,
5704    ) -> Result<()> {
5705        let remote_checkpoint = if self.state_store.writes_pr() {
5706            self.record_failed_write(remote_state)?
5707                .map(|saved| saved.checkpoint)
5708                .unwrap_or_default()
5709        } else {
5710            0
5711        };
5712        let local_checkpoint = self
5713            .state_store
5714            .writes_local()
5715            .then(|| self.read_local_state(number))
5716            .flatten()
5717            .map(|saved| saved.checkpoint)
5718            .unwrap_or_default();
5719        let mut stamped = state.clone();
5720        stamped.checkpoint = state
5721            .checkpoint
5722            .max(local_checkpoint)
5723            .max(remote_checkpoint)
5724            .max(self.remembered_checkpoint(number))
5725            .saturating_add(1);
5726        self.remember_checkpoint(number, stamped.checkpoint);
5727        if self.state_store.writes_local() {
5728            write_json_atomic(&self.state_path(number), &stamped)?;
5729        }
5730        if self.state_store.writes_pr() {
5731            self.write_pr_state(number, &stamped)?;
5732        }
5733        Ok(())
5734    }
5735
5736    fn remembered_checkpoint(&self, number: i64) -> u64 {
5737        self.checkpoints
5738            .lock()
5739            .unwrap_or_else(std::sync::PoisonError::into_inner)
5740            .get(&number)
5741            .copied()
5742            .unwrap_or_default()
5743    }
5744
5745    fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
5746        let mut checkpoints = self
5747            .checkpoints
5748            .lock()
5749            .unwrap_or_else(std::sync::PoisonError::into_inner);
5750        let saved = checkpoints.entry(number).or_default();
5751        *saved = (*saved).max(checkpoint);
5752    }
5753
5754    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5755        // Not run through clean(): this is structured data, and scrubbing would
5756        // corrupt refutation text stored in the ledger. It sits inside an
5757        // unclosed HTML comment so GitHub renders it as nothing.
5758        let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
5759        let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
5760        let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
5761        if let Some(id) = comment_id {
5762            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
5763            let field = format!("body={body}");
5764            let written = self
5765                .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
5766                .map(|_| ());
5767            return self.record_write(written);
5768        }
5769        let written = self
5770            .gh(&["pr", "comment", &number.to_string(), "--body", &body])
5771            .map(|_| ());
5772        self.record_write(written)
5773    }
5774
5775    /// Top level comments. Works for issues and pull requests alike, because
5776    /// GitHub serves both from the issues endpoint.
5777    ///
5778    /// Nothing when they cannot be read, which suits a reader that is going to
5779    /// go on regardless. A caller deciding whether it has already written here
5780    /// wants `try_issue_comments`, since for that one no comments and no answer
5781    /// are opposite answers.
5782    pub fn issue_comments(&self, number: i64) -> Vec<Value> {
5783        self.try_issue_comments(number).unwrap_or_default()
5784    }
5785
5786    pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
5787        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
5788        try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
5789    }
5790
5791    /// spar's own state comments on a pull request, newest last.
5792    ///
5793    /// Three tests, because the answer decides what gets patched over and what
5794    /// gets deleted. A comment matching the marker loosely, which is what
5795    /// somebody asking "why is there a `<!-- spar:state` block on this PR?"
5796    /// writes, is named in the log and left exactly where it is.
5797    fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
5798        let viewer = self.viewer_login()?.to_string();
5799        let mut mine = Vec::new();
5800        let mut theirs = Vec::new();
5801        for comment in self.try_issue_comments(number)? {
5802            let Some(body) = comment.get("body").and_then(Value::as_str) else {
5803                continue;
5804            };
5805            if !body.contains("spar:state") {
5806                continue;
5807            }
5808            let Some(id) = comment.get("id").and_then(Value::as_i64) else {
5809                continue;
5810            };
5811            let author = comment
5812                .get("user")
5813                .and_then(|u| u.get("login"))
5814                .and_then(Value::as_str)
5815                .unwrap_or_default();
5816            if is_state_comment(body, author, &viewer) {
5817                mine.push((id, body.to_string()));
5818            } else {
5819                theirs.push(format!("@{author}"));
5820            }
5821        }
5822        if !theirs.is_empty() {
5823            logdim!(
5824                "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
5825                 own state block is patched or deleted.",
5826                theirs.len(),
5827                theirs.join(", ")
5828            );
5829        }
5830        Ok(mine)
5831    }
5832
5833    fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
5834        Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
5835    }
5836
5837    /// Drop state once the PR is finished and there is nothing to resume.
5838    pub fn clear_state(&self, number: i64) {
5839        let path = self.state_path(number);
5840        let _ = std::fs::remove_file(&path);
5841        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
5842    }
5843
5844    // -- housekeeping -----------------------------------------------------
5845
5846    /// Remove state files whose PR is merged or closed: the custody loop's
5847    /// state, the review record, and the check-in watermark alike. Grouped by
5848    /// number so each finished pull request costs one `gh` read however many
5849    /// files it left.
5850    pub fn prune_state(&self) -> Vec<String> {
5851        let base = self.root.join(STATE_DIR).join("state");
5852        let Ok(entries) = std::fs::read_dir(&base) else {
5853            return Vec::new();
5854        };
5855        let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
5856        for name in entries
5857            .flatten()
5858            .filter_map(|e| e.file_name().to_str().map(str::to_string))
5859        {
5860            if let Some(number) = state_file_number(&name) {
5861                by_number.entry(number).or_default().push(name);
5862            }
5863        }
5864
5865        let mut removed = Vec::new();
5866        for (number, mut names) in by_number {
5867            if !is_finished(&self.pr_state(number)) {
5868                continue;
5869            }
5870            names.sort();
5871            for name in names {
5872                let _ = std::fs::remove_file(base.join(&name));
5873                removed.push(format!("state {name}"));
5874            }
5875        }
5876        removed
5877    }
5878
5879    /// Delete state comments from PRs that are finished.
5880    ///
5881    /// Open PRs are left alone: their state may still be live.
5882    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
5883        #[derive(Deserialize)]
5884        struct Row {
5885            number: i64,
5886        }
5887        let numbers = match numbers {
5888            Some(numbers) => numbers,
5889            None => {
5890                let listed: Result<Vec<i64>> = (|| {
5891                    let text = self.gh(&[
5892                        "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
5893                    ])?;
5894                    let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5895                        .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
5896                    Ok(rows.into_iter().map(|row| row.number).collect())
5897                })();
5898                match self.record_failed_write(listed) {
5899                    Ok(numbers) => numbers,
5900                    Err(e) => {
5901                        logdim!("could not inspect pull requests for state cleanup: {e}");
5902                        return Vec::new();
5903                    }
5904                }
5905            }
5906        };
5907
5908        let mut removed = Vec::new();
5909        for number in numbers {
5910            let state = match self.record_failed_write(self.try_pr_state(number)) {
5911                Ok(state) => state,
5912                Err(e) => {
5913                    logdim!("could not inspect PR #{number} for state cleanup: {e}");
5914                    continue;
5915                }
5916            };
5917            if !is_finished(&state) {
5918                continue;
5919            }
5920            let comments = match self.record_failed_write(self.try_state_comments(number)) {
5921                Ok(comments) => comments,
5922                Err(e) => {
5923                    logdim!("could not inspect state comments on PR #{number}: {e}");
5924                    continue;
5925                }
5926            };
5927            for (id, _) in comments {
5928                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
5929                let deleted = self
5930                    .gh(&["api", "-X", "DELETE", &path, "--silent"])
5931                    .map(|_| ());
5932                match self.record_write(deleted) {
5933                    Ok(()) => removed.push(format!("state comment on PR #{number}")),
5934                    Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
5935                }
5936            }
5937        }
5938        removed
5939    }
5940
5941    /// Drop worktrees whose PR is finished, then the branches they left behind.
5942    ///
5943    /// With auto_merge off, which is the default, a run ends at "approved", so
5944    /// nothing would ever clean these up on its own and they accumulate one per
5945    /// run. A stranded worktree also holds its branch checked out, which makes
5946    /// a later `gh pr merge --delete-branch` fail to clean up.
5947    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
5948        let base = self.root.join(WORKTREE_DIR);
5949        let mut removed = Vec::new();
5950        let known = self.known_branches();
5951
5952        if let Ok(entries) = std::fs::read_dir(&base) {
5953            let mut names: Vec<String> = entries
5954                .flatten()
5955                .filter(|e| e.path().is_dir())
5956                .filter_map(|e| e.file_name().to_str().map(str::to_string))
5957                .collect();
5958            names.sort();
5959
5960            for name in names {
5961                // A review worktree is detached and owns no branch, so it is
5962                // tied to the pull request only by its directory name.
5963                if let Some(rest) = name.strip_prefix("review-") {
5964                    let number: i64 = rest.parse().unwrap_or(-1);
5965                    if !(force_all || is_finished(&self.pr_state(number))) {
5966                        continue;
5967                    }
5968                    let path = base.join(&name);
5969                    if force_all {
5970                        let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
5971                            if !belongs {
5972                                return Ok(false);
5973                            }
5974                            let local_ref = review_ref(number);
5975                            if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
5976                                return Ok(false);
5977                            }
5978                            let head = self.head_oid_checked(&path)?;
5979                            let recorded = self
5980                                .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
5981                                .trim()
5982                                .to_string();
5983                            Ok(head == recorded)
5984                        });
5985                        match owned {
5986                            Ok(true) => {}
5987                            Ok(false) => {
5988                                logdim!(
5989                                    "kept {} because no matching SPAR review reference proves \
5990                                     ownership",
5991                                    path.display()
5992                                );
5993                                continue;
5994                            }
5995                            Err(e) => {
5996                                logdim!(
5997                                    "kept {} because review ownership could not be verified: {}",
5998                                    path.display(),
5999                                    e.last_line()
6000                                );
6001                                continue;
6002                            }
6003                        }
6004                    } else {
6005                        if let Err(e) = self.refuse_review_worktree_changes(number) {
6006                            logdim!(
6007                                "kept {} because its review state could not be verified as \
6008                                 disposable: {}",
6009                                path.display(),
6010                                e.last_line()
6011                            );
6012                            continue;
6013                        }
6014                    }
6015                    if force_all {
6016                        if self.remove_worktree_at_force(&path) {
6017                            self.git_try(&["update-ref", "-d", &review_ref(number)]);
6018                        }
6019                    } else {
6020                        self.release_review_worktree(number);
6021                    }
6022                    if !path.exists() {
6023                        removed.push(name);
6024                    }
6025                    continue;
6026                }
6027                let branch = format!("{}{name}", self.branch_prefix);
6028                if !(force_all || self.worktree_is_done(&branch)) {
6029                    continue;
6030                }
6031                if !known.contains_key(&branch) {
6032                    logdim!("kept {branch} because it has no branch record");
6033                    continue;
6034                }
6035                let path = base.join(&name);
6036                if !force_all {
6037                    match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6038                        Ok(true) => {
6039                            logdim!(
6040                                "kept {} because it contains uncommitted changes or ignored files",
6041                                path.display()
6042                            );
6043                            continue;
6044                        }
6045                        Err(e) => {
6046                            logdim!(
6047                                "kept {} because its Git state could not be checked: {}",
6048                                path.display(),
6049                                e.last_line()
6050                            );
6051                            continue;
6052                        }
6053                        Ok(false) => {}
6054                    }
6055                    match self.branch_deletion_is_safe(&branch) {
6056                        Ok(true) => {}
6057                        Ok(false) => {
6058                            logdim!(
6059                                "kept {branch} because no surviving ref preserves its tip or \
6060                                 reflog-only commits"
6061                            );
6062                            continue;
6063                        }
6064                        Err(e) => {
6065                            logdim!(
6066                                "kept {branch} because preservation could not be verified: {}",
6067                                e.last_line()
6068                            );
6069                            continue;
6070                        }
6071                    }
6072                }
6073                let removed_worktree = if force_all {
6074                    self.remove_worktree_at_force(&path)
6075                } else {
6076                    match self.remove_worktree_at(&path) {
6077                        Ok(removed) => removed,
6078                        Err(error) => {
6079                            logdim!(
6080                                "kept {branch} and {} because removal did not reach a confirmed \
6081                                 quiet point: {}",
6082                                path.display(),
6083                                error.last_line()
6084                            );
6085                            false
6086                        }
6087                    }
6088                };
6089                if !removed_worktree {
6090                    continue;
6091                }
6092                if force_all {
6093                    self.git_try(&["branch", "-D", &branch]);
6094                    self.forget_branch(&branch);
6095                } else {
6096                    match self.delete_branch_if_safe(&branch) {
6097                        Ok(true) => self.forget_branch(&branch),
6098                        Ok(false) => logdim!(
6099                            "kept {branch} because its tip or reflog changed before deletion"
6100                        ),
6101                        Err(error) => logdim!(
6102                            "kept {branch} because deletion safety could not be rechecked: {}",
6103                            error.last_line()
6104                        ),
6105                    }
6106                }
6107                removed.push(name);
6108            }
6109        }
6110        removed.extend(self.prune_branches(force_all));
6111        removed
6112    }
6113
6114    /// Delete leftover branches spar created whose worktree is already gone.
6115    ///
6116    /// Deletion is driven by the ledger of branches spar actually created, not
6117    /// by a name pattern. Names default to `issue-N`, which is exactly what a
6118    /// person would call a branch themselves, so a name alone can never
6119    /// establish ownership. This is the data loss guard.
6120    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
6121        let known = self.known_branches();
6122        let branches: Vec<String> = known.keys().cloned().collect();
6123        if branches.is_empty() {
6124            return Vec::new();
6125        }
6126
6127        let checked_out: Vec<String> = self
6128            .git_try(&["worktree", "list", "--porcelain"])
6129            .lines()
6130            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
6131            .collect();
6132
6133        // %(refname:short) is ambiguous when a tag shares the branch name (it
6134        // yields "heads/..."), so take the full ref and strip it here.
6135        let existing: Vec<String> = self
6136            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
6137            .lines()
6138            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
6139            .collect();
6140
6141        let mut removed = Vec::new();
6142        for branch in branches {
6143            if !existing.contains(&branch) {
6144                self.forget_branch(&branch); // already gone, drop the record
6145                continue;
6146            }
6147            if checked_out.contains(&branch) {
6148                continue;
6149            }
6150            if !(force_all || self.worktree_is_done(&branch)) {
6151                continue;
6152            }
6153            if !force_all {
6154                let Some(_record) = known.get(&branch) else {
6155                    continue;
6156                };
6157                match self.branch_deletion_is_safe(&branch) {
6158                    Ok(true) => {}
6159                    Ok(false) => {
6160                        logdim!(
6161                            "kept {branch} because no surviving ref preserves its tip or \
6162                             reflog-only commits"
6163                        );
6164                        continue;
6165                    }
6166                    Err(e) => {
6167                        logdim!(
6168                            "kept {branch} because preservation could not be verified: {}",
6169                            e.last_line()
6170                        );
6171                        continue;
6172                    }
6173                }
6174            }
6175            let deleted = if force_all {
6176                self.git(&["branch", "-D", &branch]).map(|_| true)
6177            } else {
6178                self.delete_branch_if_safe(&branch)
6179            };
6180            match deleted {
6181                Ok(true) => {
6182                    self.forget_branch(&branch);
6183                    removed.push(format!("branch {branch}"));
6184                }
6185                Ok(false) => {
6186                    logdim!("kept {branch} because its tip or reflog changed before deletion");
6187                }
6188                Err(e) => {
6189                    // A branch that silently survives pruning looks like a spar
6190                    // bug, so the name and git's own reason have to be said.
6191                    logdim!("could not delete {branch}: {}", e.last_line());
6192                }
6193            }
6194        }
6195        removed
6196    }
6197
6198    /// True when the PR behind this branch is merged or closed.
6199    fn worktree_is_done(&self, branch: &str) -> bool {
6200        #[derive(Deserialize)]
6201        struct Row {
6202            state: String,
6203        }
6204        let entry = branch
6205            .strip_prefix(self.branch_prefix.as_str())
6206            .unwrap_or(branch);
6207        if let Some(rest) = entry.strip_prefix("pr-") {
6208            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
6209        }
6210        // A split part is the same shape as an issue branch: one branch, whose
6211        // pull requests say whether it is finished. Without it here, a part
6212        // branch is one nothing but `clean --all` would ever remove.
6213        if entry.starts_with("issue-") || entry.starts_with("split-") {
6214            let text = self.gh_try(&[
6215                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
6216            ]);
6217            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
6218            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
6219        }
6220        false
6221    }
6222}
6223
6224// ---------------------------------------------------------------------------
6225// Free helpers
6226// ---------------------------------------------------------------------------
6227
6228/// Take a read-only probe again before treating its failure as evidence.
6229///
6230/// Every probe is a Git invocation under a thirty second cap plus a walk over a
6231/// tree an agent may still be building in. A probe that fails proves nothing
6232/// about the worktree, and there is a real difference between "the tree moved"
6233/// and "the question could not be asked just then", which the caller turns into
6234/// the same run-ending error.
6235pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
6236    match probe() {
6237        Ok(value) => Ok(value),
6238        Err(first) => {
6239            logdim!("retrying a worktree probe after: {}", first.last_line());
6240            probe()
6241        }
6242    }
6243}
6244
6245/// Read attribute files without asking Git to inspect working-tree content.
6246///
6247/// A newly written attribute can select a clean or smudge filter. It must be
6248/// detected before a post-call status, diff, or add command has a chance to run
6249/// that filter in the parent process.
6250pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
6251    let root = std::fs::canonicalize(cwd)
6252        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6253    let mut files = BTreeMap::new();
6254    let mut visited = BTreeSet::new();
6255    collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
6256    Ok(AttributeState { files })
6257}
6258
6259fn collect_attribute_files(
6260    root: &Path,
6261    repository: &Path,
6262    prefix: &Path,
6263    visited: &mut BTreeSet<PathBuf>,
6264    files: &mut BTreeMap<PathBuf, [u8; 32]>,
6265) -> Result<()> {
6266    let canonical = std::fs::canonicalize(repository)
6267        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6268    if !visited.insert(canonical) {
6269        bail!("submodule recursion revisited {}", repository.display());
6270    }
6271    let entries = index_entries(repository)?;
6272    let mut paths: BTreeSet<PathBuf> = entries
6273        .iter()
6274        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6275        .map(|entry| entry.path.clone())
6276        .collect();
6277    // Ignored on purpose. A `.gitattributes` under build output can only select
6278    // filters for paths that are themselves ignored, and no ignored path is
6279    // ever staged or compared as tracked content, so it cannot reach a managed
6280    // commit. Capturing it meant `cargo package` or `npm ci` writing one into
6281    // its own output ended a read-only call outright.
6282    paths.extend(untracked_attribute_paths(repository)?);
6283    for path in paths {
6284        let from_root = prefix.join(&path);
6285        let state = attribute_file_fingerprint(&root.join(&from_root))?;
6286        files.insert(from_root, state);
6287    }
6288    for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
6289        let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
6290            continue;
6291        };
6292        collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
6293    }
6294    Ok(())
6295}
6296
6297/// Leave a visible, untracked reason ordinary cleanup can detect on a later
6298/// run even when Git status normalizes the original working-file change away.
6299pub(crate) fn uncertain_worktree_change(
6300    cwd: &Path,
6301    message: impl Into<String>,
6302) -> crate::error::SparError {
6303    let message = message.into();
6304    let marker = write_recovery_marker(cwd, &message);
6305    let note = match marker {
6306        Ok(path) => format!(" Recovery marker: {}.", path.display()),
6307        Err(e) => format!(
6308            " A recovery marker could not be written: {}.",
6309            e.last_line()
6310        ),
6311    };
6312    crate::error::SparError::uncertain_write(format!("{message}{note}"))
6313}
6314
6315fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
6316    use std::sync::atomic::{AtomicU32, Ordering};
6317    static NEXT: AtomicU32 = AtomicU32::new(0);
6318    for _ in 0..1000 {
6319        let serial = NEXT.fetch_add(1, Ordering::Relaxed);
6320        let path = cwd.join(format!(
6321            "{RECOVERY_MARKER_PREFIX}{}-{serial}",
6322            std::process::id()
6323        ));
6324        let mut options = OpenOptions::new();
6325        options.write(true).create_new(true);
6326        #[cfg(unix)]
6327        {
6328            use std::os::unix::fs::OpenOptionsExt;
6329            options.mode(0o600);
6330        }
6331        match options.open(&path) {
6332            Ok(mut file) => {
6333                file.write_all(detail.as_bytes())
6334                    .and_then(|_| file.write_all(b"\n"))
6335                    .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6336                return Ok(path);
6337            }
6338            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
6339            Err(e) => {
6340                return Err(spar_err!(
6341                    "could not create a recovery marker in {}: {e}",
6342                    cwd.display()
6343                ))
6344            }
6345        }
6346    }
6347    bail!(
6348        "could not choose a free recovery marker name in {}",
6349        cwd.display()
6350    )
6351}
6352
6353/// Build a Git command that cannot launch automatic repository maintenance.
6354///
6355/// A fetch may otherwise prune missing linked worktree registrations. SPAR
6356/// must only remove registrations it has proven it owns.
6357fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
6358    let mut argv = vec![
6359        "git".to_string(),
6360        "-c".to_string(),
6361        "maintenance.auto=false".to_string(),
6362        "-c".to_string(),
6363        "gc.auto=0".to_string(),
6364    ];
6365    argv.extend(args.iter().map(|arg| (*arg).to_string()));
6366    argv
6367}
6368
6369fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
6370    let mut argv = git_without_maintenance_argv(&[]);
6371    argv.extend([
6372        "-c".to_string(),
6373        "core.fsmonitor=".to_string(),
6374        "-c".to_string(),
6375        "commit.gpgsign=false".to_string(),
6376        "-c".to_string(),
6377        "core.hooksPath=/dev/null".to_string(),
6378    ]);
6379    argv.extend(args.iter().map(|arg| (*arg).to_string()));
6380    argv
6381}
6382
6383/// Snapshot every untracked file, including ignored files, without changing
6384/// path bytes.
6385///
6386/// Without an exclude option, Git lists both ordinary and ignored untracked
6387/// entries. Metadata fingerprints make overwriting an existing path observable
6388/// without hashing a potentially multi-gigabyte build tree on every call.
6389pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
6390    let root = std::fs::canonicalize(cwd)
6391        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6392    let mut files = BTreeMap::new();
6393    let mut ignored = BTreeSet::new();
6394    let mut visited = BTreeSet::new();
6395    collect_untracked_files(
6396        &root,
6397        &root,
6398        Path::new(""),
6399        &mut visited,
6400        &mut files,
6401        &mut ignored,
6402    )?;
6403    Ok(IgnoredState { files, ignored })
6404}
6405
6406fn collect_untracked_files(
6407    root: &Path,
6408    repository: &Path,
6409    prefix: &Path,
6410    visited: &mut BTreeSet<PathBuf>,
6411    files: &mut BTreeMap<PathBuf, UntrackedFile>,
6412    ignored: &mut BTreeSet<PathBuf>,
6413) -> Result<()> {
6414    let canonical = std::fs::canonicalize(repository)
6415        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6416    if !visited.insert(canonical.clone()) {
6417        bail!("submodule recursion revisited {}", canonical.display());
6418    }
6419    // Classify ordinary paths in the listing itself. Listing every path here
6420    // and marking ignored paths in a second query misclassifies build output
6421    // deleted between queries as ordinary untracked work.
6422    let listed = run_git_bytes(
6423        repository,
6424        &["ls-files", "--others", "--exclude-standard", "-z"],
6425    )?;
6426    if !listed.is_empty() && !listed.ends_with(&[0]) {
6427        bail!(
6428            "git returned an unterminated untracked-file list for {}",
6429            repository.display()
6430        );
6431    }
6432
6433    for raw in listed
6434        .split(|byte| *byte == 0)
6435        .filter(|raw| !raw.is_empty())
6436    {
6437        let (relative, nested) = untracked_record(raw, "untracked")?;
6438        let from_root = prefix.join(&relative);
6439        if is_spar_own_path(&from_root) {
6440            continue;
6441        }
6442        let absolute = root.join(&from_root);
6443        let fingerprint = if nested {
6444            nested_repository_fingerprint(&absolute)?
6445        } else {
6446            ignored_file_fingerprint(&absolute)?
6447        };
6448        if files.insert(from_root.clone(), fingerprint).is_some() {
6449            bail!(
6450                "git returned the untracked path more than once: {:?}",
6451                from_root
6452            );
6453        }
6454    }
6455
6456    let ignored_listed = run_git_bytes(
6457        repository,
6458        &[
6459            "ls-files",
6460            "--others",
6461            "--ignored",
6462            "--exclude-standard",
6463            "-z",
6464        ],
6465    )?;
6466    if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
6467        bail!(
6468            "git returned an unterminated ignored-file list for {}",
6469            repository.display()
6470        );
6471    }
6472    // Two `ls-files` runs cannot be made to agree about a tree that is being
6473    // built in. A path named only by the second one was created between them,
6474    // which is a fact about the moment, not a corruption: record it and let the
6475    // comparison decide, rather than ending a run that had nothing wrong with
6476    // it.
6477    for raw in ignored_listed
6478        .split(|byte| *byte == 0)
6479        .filter(|raw| !raw.is_empty())
6480    {
6481        let (relative, nested) = untracked_record(raw, "ignored")?;
6482        let from_root = prefix.join(relative);
6483        if is_spar_own_path(&from_root) {
6484            continue;
6485        }
6486        if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
6487            let absolute = root.join(&from_root);
6488            let fingerprint = if nested {
6489                nested_repository_fingerprint(&absolute)?
6490            } else {
6491                ignored_file_fingerprint(&absolute)?
6492            };
6493            slot.insert(fingerprint);
6494        }
6495        if !ignored.insert(from_root.clone()) {
6496            bail!(
6497                "git returned the ignored path more than once: {:?}",
6498                from_root
6499            );
6500        }
6501    }
6502
6503    for link in gitlinks(repository)? {
6504        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6505            continue;
6506        };
6507        collect_untracked_files(
6508            root,
6509            &submodule,
6510            &prefix.join(&link.path),
6511            visited,
6512            files,
6513            ignored,
6514        )?;
6515    }
6516    Ok(())
6517}
6518
6519fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
6520    let argv = git_without_automation_argv(args);
6521    proc::run_bytes(
6522        &argv,
6523        &ExecOpts::new()
6524            .cwd(cwd)
6525            .timeout_secs(30)
6526            .stop_descendants(true),
6527    )
6528}
6529
6530fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
6531    let argv = git_without_automation_argv(args);
6532    proc::run(
6533        &argv,
6534        &ExecOpts::new()
6535            .cwd(cwd)
6536            .timeout_secs(30)
6537            .stop_descendants(true),
6538    )
6539}
6540
6541fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
6542    let path = path.to_str().ok_or_else(|| {
6543        spar_err!(
6544            "cannot verify filtered content for a non-UTF-8 path in {}",
6545            cwd.display()
6546        )
6547    })?;
6548    let path_arg = format!("--path={path}");
6549    let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
6550    Ok(Sha256::digest(bytes).into())
6551}
6552
6553fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
6554    let relative = path_from_git_bytes(raw)?;
6555    if relative.is_absolute()
6556        || relative.components().any(|component| {
6557            matches!(
6558                component,
6559                std::path::Component::ParentDir
6560                    | std::path::Component::RootDir
6561                    | std::path::Component::Prefix(_)
6562            )
6563        })
6564    {
6565        bail!("git returned an unsafe {kind} path: {:?}", relative);
6566    }
6567    Ok(relative)
6568}
6569
6570/// Split one `ls-files --others` record into its path and whether Git reported
6571/// a nested repository rather than a single file.
6572///
6573/// Git never lists the contents of a repository inside the working tree, so a
6574/// checkout parked there, such as another of SPAR's own worktrees, arrives as
6575/// one record for the directory itself ending in a separator. Git writes that
6576/// separator on every platform. Trimming it keeps the recorded path equal to
6577/// the same path seen any other way.
6578fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
6579    let nested = raw.last() == Some(&b'/');
6580    let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
6581    if trimmed.is_empty() {
6582        bail!("git returned an empty {kind} path");
6583    }
6584    Ok((safe_git_path(trimmed, kind)?, nested))
6585}
6586
6587fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
6588    let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
6589    if !listed.is_empty() && !listed.ends_with(&[0]) {
6590        bail!(
6591            "git returned an unterminated index listing for {}",
6592            cwd.display()
6593        );
6594    }
6595    let mut entries = Vec::new();
6596    for record in listed
6597        .split(|byte| *byte == 0)
6598        .filter(|record| !record.is_empty())
6599    {
6600        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6601            bail!(
6602                "git returned a malformed index record for {}",
6603                cwd.display()
6604            );
6605        };
6606        let header = &record[..tab];
6607        let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
6608        if fields.len() != 3 {
6609            bail!(
6610                "git returned a malformed index header for {}",
6611                cwd.display()
6612            );
6613        }
6614        if fields[2] != b"0" {
6615            continue;
6616        }
6617        let mode = std::str::from_utf8(fields[0])
6618            .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
6619            .to_string();
6620        let oid = std::str::from_utf8(fields[1])
6621            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6622            .to_string();
6623        entries.push(IndexEntry {
6624            path: safe_git_path(&record[tab + 1..], "index")?,
6625            mode,
6626            oid,
6627        });
6628    }
6629    Ok(entries)
6630}
6631
6632/// Share Git's complete ignore policy between call checkpoints and handoffs.
6633fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
6634    let listed = run_git_bytes(
6635        cwd,
6636        &[
6637            "ls-files",
6638            "--others",
6639            "--exclude-standard",
6640            "-z",
6641            "--",
6642            ".gitattributes",
6643            ":(glob)**/.gitattributes",
6644        ],
6645    )?;
6646    if !listed.is_empty() && !listed.ends_with(&[0]) {
6647        bail!(
6648            "git returned an unterminated attribute-file listing for {}",
6649            cwd.display()
6650        );
6651    }
6652    listed
6653        .split(|byte| *byte == 0)
6654        .filter(|record| !record.is_empty())
6655        .map(|raw| safe_git_path(raw, "attribute"))
6656        .collect()
6657}
6658
6659fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
6660    if !untracked_attribute_paths(cwd)?.is_empty() {
6661        return Ok(true);
6662    }
6663
6664    let index = index_entries(cwd)?
6665        .into_iter()
6666        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6667        .map(|entry| (entry.path, (entry.mode, entry.oid)))
6668        .collect::<BTreeMap<_, _>>();
6669    let head = tree_entries(cwd, "HEAD")?
6670        .into_iter()
6671        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6672        .map(|entry| (entry.path, (entry.mode, entry.oid)))
6673        .collect::<BTreeMap<_, _>>();
6674    if index != head {
6675        return Ok(true);
6676    }
6677
6678    let effective = check_attributes(cwd, index.keys().cloned())?;
6679    let config = CheckoutConfig::read(cwd)?;
6680    for (path, (_mode, oid)) in index {
6681        let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
6682            return Ok(true);
6683        };
6684        let attributes = effective
6685            .get(&path)
6686            .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
6687        if allows_expected_crlf(&config, attributes)? {
6688            if worktree.mode == "120000" {
6689                return Ok(true);
6690            }
6691            let (normalized, every_lf_was_crlf) =
6692                normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
6693            if !every_lf_was_crlf || normalized != oid {
6694                return Ok(true);
6695            }
6696        } else if worktree.raw_oid != oid {
6697            return Ok(true);
6698        }
6699    }
6700    Ok(false)
6701}
6702
6703fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
6704    Ok(index_entries(cwd)?
6705        .into_iter()
6706        .filter(|entry| entry.mode == "160000")
6707        .map(|entry| Gitlink {
6708            path: entry.path,
6709            oid: entry.oid,
6710        })
6711        .collect())
6712}
6713
6714fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
6715    let mut tracked = BTreeMap::new();
6716    for entry in index_entries(cwd)? {
6717        if entry.mode == "160000" {
6718            continue;
6719        }
6720        let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
6721        tracked.insert(
6722            entry.path,
6723            TrackedEntry {
6724                index_mode: entry.mode,
6725                index_oid: entry.oid,
6726                worktree,
6727            },
6728        );
6729    }
6730    Ok(tracked)
6731}
6732
6733fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
6734    let metadata = match std::fs::symlink_metadata(path) {
6735        Ok(metadata) => metadata,
6736        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6737        Err(e) => {
6738            return Err(spar_err!(
6739                "could not inspect tracked file {}: {e}",
6740                path.display()
6741            ))
6742        }
6743    };
6744    let mut fingerprint = Sha256::new();
6745    if metadata.file_type().is_symlink() {
6746        let target = std::fs::read_link(path)
6747            .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
6748        let bytes = os_str_bytes(target.as_os_str())?;
6749        fingerprint.update(b"symlink\0");
6750        fingerprint.update(&bytes);
6751        let content = Sha256::digest(&bytes).into();
6752        return Ok(Some(WorktreeFile {
6753            mode: "120000".to_string(),
6754            #[cfg(unix)]
6755            permissions: 0,
6756            raw_oid: git_blob_oid(oid_len, &bytes)?,
6757            fingerprint: fingerprint.finalize().into(),
6758            content,
6759        }));
6760    }
6761    if !metadata.is_file() {
6762        bail!("tracked path {} is not a file or symlink", path.display());
6763    }
6764
6765    let mut options = OpenOptions::new();
6766    options.read(true);
6767    #[cfg(unix)]
6768    {
6769        use std::os::unix::fs::OpenOptionsExt;
6770        options.custom_flags(libc::O_NOFOLLOW);
6771    }
6772    let mut file = options
6773        .open(path)
6774        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6775    let before = file
6776        .metadata()
6777        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6778    let mode = tracked_file_mode(&before);
6779    #[cfg(unix)]
6780    let permissions = {
6781        use std::os::unix::fs::MetadataExt;
6782        before.mode() & 0o7777
6783    };
6784    fingerprint.update(b"file\0");
6785    fingerprint.update(mode.as_bytes());
6786    #[cfg(unix)]
6787    fingerprint.update(permissions.to_le_bytes());
6788    fingerprint.update(before.len().to_le_bytes());
6789    let mut content = Sha256::new();
6790    let header = format!("blob {}\0", before.len());
6791    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
6792    let mut buf = [0u8; 64 * 1024];
6793    loop {
6794        let read = file
6795            .read(&mut buf)
6796            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6797        if read == 0 {
6798            break;
6799        }
6800        fingerprint.update(&buf[..read]);
6801        content.update(&buf[..read]);
6802        object.update(&buf[..read]);
6803    }
6804    let after = file
6805        .metadata()
6806        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6807    if before.len() != after.len()
6808        || before.modified().ok() != after.modified().ok()
6809        || before.permissions() != after.permissions()
6810    {
6811        bail!(
6812            "tracked file {} changed while it was being inspected",
6813            path.display()
6814        );
6815    }
6816    let current = std::fs::symlink_metadata(path)
6817        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6818    if !same_file(&after, &current) {
6819        bail!(
6820            "tracked file {} was replaced while it was being inspected",
6821            path.display()
6822        );
6823    }
6824    Ok(Some(WorktreeFile {
6825        mode,
6826        #[cfg(unix)]
6827        permissions,
6828        raw_oid: object.finish(),
6829        fingerprint: fingerprint.finalize().into(),
6830        content: content.finalize().into(),
6831    }))
6832}
6833
6834fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
6835    let metadata = std::fs::symlink_metadata(path)
6836        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
6837    let mut digest = Sha256::new();
6838    if metadata.file_type().is_symlink() {
6839        digest.update(b"symlink\0");
6840        let target = std::fs::read_link(path)
6841            .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
6842        digest.update(os_str_bytes(target.as_os_str())?);
6843        return Ok(digest.finalize().into());
6844    }
6845    if !metadata.is_file() {
6846        bail!("attribute path {} is not a file or symlink", path.display());
6847    }
6848    let mut options = OpenOptions::new();
6849    options.read(true);
6850    #[cfg(unix)]
6851    {
6852        use std::os::unix::fs::OpenOptionsExt;
6853        options.custom_flags(libc::O_NOFOLLOW);
6854    }
6855    let mut file = options
6856        .open(path)
6857        .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
6858    let before = file
6859        .metadata()
6860        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
6861    digest.update(b"file\0");
6862    let mut buf = [0u8; 64 * 1024];
6863    loop {
6864        let read = file
6865            .read(&mut buf)
6866            .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
6867        if read == 0 {
6868            break;
6869        }
6870        digest.update(&buf[..read]);
6871    }
6872    let after = file
6873        .metadata()
6874        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
6875    let current = std::fs::symlink_metadata(path)
6876        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
6877    if before.len() != after.len()
6878        || before.modified().ok() != after.modified().ok()
6879        || !same_file(&after, &current)
6880    {
6881        bail!(
6882            "attribute file {} changed while it was being inspected",
6883            path.display()
6884        );
6885    }
6886    Ok(digest.finalize().into())
6887}
6888
6889enum ObjectHasher {
6890    Sha1(Sha1),
6891    Sha256(Sha256),
6892}
6893
6894impl ObjectHasher {
6895    fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
6896        let mut hasher = match oid_len {
6897            40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
6898            64 => Self::Sha256(Sha256::new()),
6899            _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
6900        };
6901        hasher.update(header);
6902        Ok(hasher)
6903    }
6904
6905    fn update(&mut self, bytes: &[u8]) {
6906        match self {
6907            Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
6908            Self::Sha256(hasher) => hasher.update(bytes),
6909        }
6910    }
6911
6912    fn finish(self) -> String {
6913        let bytes = match self {
6914            Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
6915            Self::Sha256(hasher) => hasher.finalize().to_vec(),
6916        };
6917        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
6918    }
6919}
6920
6921fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
6922    let header = format!("blob {}\0", bytes.len());
6923    let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
6924    hasher.update(bytes);
6925    Ok(hasher.finish())
6926}
6927
6928fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
6929    let mut first = open_regular_file(path)?;
6930    let first_before = first
6931        .metadata()
6932        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6933    let mut raw_len = 0u64;
6934    let mut crlf_pairs = 0u64;
6935    let mut previous_was_cr = false;
6936    let mut every_lf_was_crlf = true;
6937    let mut buf = [0u8; 64 * 1024];
6938    loop {
6939        let read = first
6940            .read(&mut buf)
6941            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6942        if read == 0 {
6943            break;
6944        }
6945        raw_len = raw_len
6946            .checked_add(read as u64)
6947            .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
6948        for byte in &buf[..read] {
6949            if *byte == b'\n' {
6950                if previous_was_cr {
6951                    crlf_pairs += 1;
6952                } else {
6953                    every_lf_was_crlf = false;
6954                }
6955            }
6956            previous_was_cr = *byte == b'\r';
6957        }
6958    }
6959    let first_after = first
6960        .metadata()
6961        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6962    let current = std::fs::symlink_metadata(path)
6963        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
6964    if raw_len != first_before.len()
6965        || !stable_file_metadata(&first_before, &first_after)
6966        || !stable_file_metadata(&first_after, &current)
6967    {
6968        bail!(
6969            "tracked file {} changed while line endings were inspected",
6970            path.display()
6971        );
6972    }
6973
6974    let normalized_len = raw_len
6975        .checked_sub(crlf_pairs)
6976        .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
6977    let header = format!("blob {normalized_len}\0");
6978    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
6979    let mut second = open_regular_file(path)?;
6980    let second_before = second
6981        .metadata()
6982        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6983    if !stable_file_metadata(&first_after, &second_before) {
6984        bail!(
6985            "tracked file {} changed between line-ending checks",
6986            path.display()
6987        );
6988    }
6989    let mut pending_cr = false;
6990    loop {
6991        let read = second
6992            .read(&mut buf)
6993            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6994        if read == 0 {
6995            break;
6996        }
6997        for byte in &buf[..read] {
6998            if pending_cr {
6999                if *byte == b'\n' {
7000                    object.update(b"\n");
7001                    pending_cr = false;
7002                    continue;
7003                }
7004                object.update(b"\r");
7005                pending_cr = false;
7006            }
7007            if *byte == b'\r' {
7008                pending_cr = true;
7009            } else {
7010                object.update(std::slice::from_ref(byte));
7011            }
7012        }
7013    }
7014    if pending_cr {
7015        object.update(b"\r");
7016    }
7017    let second_after = second
7018        .metadata()
7019        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7020    let current = std::fs::symlink_metadata(path)
7021        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7022    if !stable_file_metadata(&second_before, &second_after)
7023        || !stable_file_metadata(&second_after, &current)
7024    {
7025        bail!(
7026            "tracked file {} changed while line endings were hashed",
7027            path.display()
7028        );
7029    }
7030    Ok((object.finish(), every_lf_was_crlf))
7031}
7032
7033fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7034    let mut options = OpenOptions::new();
7035    options.read(true);
7036    #[cfg(unix)]
7037    {
7038        use std::os::unix::fs::OpenOptionsExt;
7039        options.custom_flags(libc::O_NOFOLLOW);
7040    }
7041    let file = options
7042        .open(path)
7043        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7044    let metadata = file
7045        .metadata()
7046        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7047    if !metadata.is_file() {
7048        bail!("tracked path {} is not a regular file", path.display());
7049    }
7050    Ok(file)
7051}
7052
7053fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7054    if !same_file(left, right)
7055        || left.len() != right.len()
7056        || left.modified().ok() != right.modified().ok()
7057        || left.permissions() != right.permissions()
7058    {
7059        return false;
7060    }
7061    #[cfg(unix)]
7062    {
7063        use std::os::unix::fs::MetadataExt;
7064        left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
7065    }
7066    #[cfg(not(unix))]
7067    {
7068        left.created().ok() == right.created().ok()
7069    }
7070}
7071
7072/// How many paths one `check-attr` call is asked about.
7073///
7074/// `check-attr` answers with three NUL fields per attribute per path, so the
7075/// output is several times the input. A whole monorepo in one call is megabytes
7076/// in both directions, and the 30 second timeout below is a timeout on the
7077/// batch. Batching keeps each call small enough to be honest about.
7078const ATTRIBUTE_BATCH: usize = 1000;
7079
7080fn check_attributes(
7081    cwd: &Path,
7082    paths: impl IntoIterator<Item = PathBuf>,
7083) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7084    let paths = paths.into_iter().collect::<BTreeSet<_>>();
7085    if paths.is_empty() {
7086        return Ok(BTreeMap::new());
7087    }
7088    let ordered = paths.into_iter().collect::<Vec<_>>();
7089    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7090    for batch in ordered.chunks(ATTRIBUTE_BATCH) {
7091        values.extend(check_attribute_batch(cwd, batch)?);
7092    }
7093    Ok(values)
7094}
7095
7096fn check_attribute_batch(
7097    cwd: &Path,
7098    batch: &[PathBuf],
7099) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7100    const NAMES: [&str; 6] = [
7101        "filter",
7102        "working-tree-encoding",
7103        "ident",
7104        "text",
7105        "eol",
7106        "crlf",
7107    ];
7108    let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
7109    let mut input = String::new();
7110    for path in &paths {
7111        let path = path.to_str().ok_or_else(|| {
7112            spar_err!(
7113                "cannot inspect attributes for a non-UTF-8 path in {}",
7114                cwd.display()
7115            )
7116        })?;
7117        input.push_str(path);
7118        input.push('\0');
7119    }
7120    let argv = git_without_automation_argv(&[
7121        "check-attr",
7122        "-z",
7123        "--cached",
7124        "--stdin",
7125        "filter",
7126        "working-tree-encoding",
7127        "ident",
7128        "text",
7129        "eol",
7130        "crlf",
7131    ]);
7132    let output = proc::run_bytes(
7133        &argv,
7134        &ExecOpts::new()
7135            .cwd(cwd)
7136            .timeout_secs(30)
7137            .stdin(input)
7138            .stop_descendants(true),
7139    )?;
7140    if !output.is_empty() && !output.ends_with(&[0]) {
7141        bail!(
7142            "git returned an unterminated attribute result for {}",
7143            cwd.display()
7144        );
7145    }
7146    let fields = output
7147        .split(|byte| *byte == 0)
7148        .filter(|field| !field.is_empty())
7149        .collect::<Vec<_>>();
7150    if fields.len() != paths.len() * NAMES.len() * 3 {
7151        bail!(
7152            "git returned an unexpected attribute result for {}",
7153            cwd.display()
7154        );
7155    }
7156    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7157    for record in fields.chunks_exact(3) {
7158        let path = safe_git_path(record[0], "attribute")?;
7159        if !paths.contains(&path) {
7160            bail!(
7161                "git returned attributes for the wrong path in {}",
7162                cwd.display()
7163            );
7164        }
7165        let name = std::str::from_utf8(record[1])
7166            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
7167        let value = std::str::from_utf8(record[2])
7168            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
7169        values
7170            .entry(path)
7171            .or_default()
7172            .insert(name.to_string(), value.to_string());
7173    }
7174    if paths.iter().any(|path| {
7175        values
7176            .get(path)
7177            .is_none_or(|attributes| attributes.len() != NAMES.len())
7178    }) {
7179        bail!(
7180            "git omitted an attribute result for a tracked path in {}",
7181            cwd.display()
7182        );
7183    }
7184    Ok(values)
7185}
7186
7187fn attribute_is_active(value: Option<&String>) -> bool {
7188    !matches!(
7189        value.map(String::as_str),
7190        None | Some("unspecified") | Some("unset")
7191    )
7192}
7193
7194fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
7195    attribute_is_active(values.get("filter"))
7196        || attribute_is_active(values.get("working-tree-encoding"))
7197}
7198
7199/// The checkout settings a per-file line-ending decision depends on.
7200///
7201/// These are constant for a worktree, but the checks that read them run once
7202/// per tracked file, and each read was its own `git config` process. A
7203/// thousand-file repository with no `.gitattributes` takes every one of those
7204/// branches, which is half a minute of process spawning per scan, and the sweep
7205/// before a run scans every finished worktree more than once. Read them here,
7206/// once, and hand them down.
7207struct CheckoutConfig {
7208    autocrlf: Option<String>,
7209    eol: Option<String>,
7210    symlinks: Option<bool>,
7211}
7212
7213impl CheckoutConfig {
7214    fn read(cwd: &Path) -> Result<Self> {
7215        Ok(Self {
7216            autocrlf: git_config_value(cwd, "core.autocrlf")?,
7217            eol: git_config_value(cwd, "core.eol")?,
7218            symlinks: git_config_bool(cwd, "core.symlinks")?,
7219        })
7220    }
7221
7222    fn autocrlf_is_true(&self) -> bool {
7223        self.autocrlf.as_ref().is_some_and(|value| {
7224            matches!(
7225                value.to_ascii_lowercase().as_str(),
7226                "true" | "yes" | "on" | "1"
7227            )
7228        })
7229    }
7230
7231    fn eol_is(&self, wanted: &str) -> bool {
7232        self.eol
7233            .as_ref()
7234            .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
7235    }
7236}
7237
7238fn path_has_ambiguous_transform(
7239    config: &CheckoutConfig,
7240    values: &BTreeMap<String, String>,
7241) -> Result<bool> {
7242    if path_has_external_transform(values)
7243        || attribute_is_active(values.get("ident"))
7244        || attribute_is_active(values.get("crlf"))
7245    {
7246        return Ok(true);
7247    }
7248    let text = values.get("text").map(String::as_str);
7249    let eol = values.get("eol").map(String::as_str);
7250    if text == Some("auto") {
7251        return Ok(true);
7252    }
7253    if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
7254        || !matches!(
7255            eol,
7256            Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
7257        )
7258    {
7259        return Ok(true);
7260    }
7261    if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
7262        return Ok(config.autocrlf_is_true());
7263    }
7264    Ok(false)
7265}
7266
7267fn allows_expected_crlf(
7268    config: &CheckoutConfig,
7269    values: &BTreeMap<String, String>,
7270) -> Result<bool> {
7271    if path_has_external_transform(values)
7272        || attribute_is_active(values.get("ident"))
7273        || attribute_is_active(values.get("crlf"))
7274    {
7275        return Ok(false);
7276    }
7277    let text = values.get("text").map(String::as_str);
7278    let eol = values.get("eol").map(String::as_str);
7279    if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
7280        return Ok(false);
7281    }
7282    if eol == Some("crlf") {
7283        return Ok(true);
7284    }
7285    if text != Some("set") {
7286        return Ok(false);
7287    }
7288    if let Some(autocrlf) = config.autocrlf.as_deref() {
7289        match autocrlf.to_ascii_lowercase().as_str() {
7290            "true" | "yes" | "on" | "1" => return Ok(true),
7291            "input" => return Ok(false),
7292            _ => {}
7293        }
7294    }
7295    if config.eol_is("crlf") {
7296        return Ok(true);
7297    }
7298    #[cfg(windows)]
7299    if config.eol.is_none() || config.eol_is("native") {
7300        return Ok(true);
7301    }
7302    Ok(false)
7303}
7304
7305fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
7306    let argv = git_without_automation_argv(&["config", "--get", key]);
7307    let output = proc::exec(
7308        &argv,
7309        &ExecOpts::new()
7310            .cwd(cwd)
7311            .timeout_secs(30)
7312            .check(false)
7313            .stop_descendants(true),
7314    )?;
7315    match output.code {
7316        0 => Ok(Some(output.stdout.trim().to_string())),
7317        1 => Ok(None),
7318        _ => bail!(
7319            "could not read Git configuration in {}: {}",
7320            cwd.display(),
7321            output.stderr.trim()
7322        ),
7323    }
7324}
7325
7326fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
7327    let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
7328    let output = proc::exec(
7329        &argv,
7330        &ExecOpts::new()
7331            .cwd(cwd)
7332            .timeout_secs(30)
7333            .check(false)
7334            .stop_descendants(true),
7335    )?;
7336    match output.code {
7337        0 if output.stdout.trim() == "true" => Ok(Some(true)),
7338        0 if output.stdout.trim() == "false" => Ok(Some(false)),
7339        0 => bail!(
7340            "git returned an invalid boolean for {key} in {}",
7341            cwd.display()
7342        ),
7343        1 => Ok(None),
7344        _ => bail!(
7345            "could not read Git configuration in {}: {}",
7346            cwd.display(),
7347            output.stderr.trim()
7348        ),
7349    }
7350}
7351
7352#[cfg(unix)]
7353fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
7354    use std::os::unix::fs::PermissionsExt;
7355    if metadata.permissions().mode() & 0o111 == 0 {
7356        "100644".to_string()
7357    } else {
7358        "100755".to_string()
7359    }
7360}
7361
7362#[cfg(not(unix))]
7363fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
7364    "100644".to_string()
7365}
7366
7367fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
7368    let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
7369    if !listed.is_empty() && !listed.ends_with(&[0]) {
7370        bail!(
7371            "git returned an unterminated tree listing for {}",
7372            cwd.display()
7373        );
7374    }
7375    let mut entries = Vec::new();
7376    for record in listed
7377        .split(|byte| *byte == 0)
7378        .filter(|record| !record.is_empty())
7379    {
7380        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7381            bail!("git returned a malformed tree record for {}", cwd.display());
7382        };
7383        let fields = record[..tab]
7384            .split(|byte| *byte == b' ')
7385            .collect::<Vec<_>>();
7386        if fields.len() != 3 {
7387            bail!("git returned a malformed tree header for {}", cwd.display());
7388        }
7389        let mode = std::str::from_utf8(fields[0])
7390            .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
7391            .to_string();
7392        let oid = std::str::from_utf8(fields[2])
7393            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7394            .to_string();
7395        entries.push(IndexEntry {
7396            path: safe_git_path(&record[tab + 1..], "tree")?,
7397            mode,
7398            oid,
7399        });
7400    }
7401    Ok(entries)
7402}
7403
7404fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
7405    Ok(tree_entries(cwd, "HEAD")?
7406        .into_iter()
7407        .filter(|entry| entry.mode == "160000")
7408        .map(|entry| (entry.path, entry.oid))
7409        .collect())
7410}
7411
7412fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
7413    let head = head_gitlinks(cwd)?;
7414    let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
7415        .into_iter()
7416        .map(|link| (link.path, link.oid))
7417        .collect();
7418    let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
7419    paths.extend(index.keys().cloned());
7420    Ok(paths
7421        .into_iter()
7422        .filter(|path| head.get(path) != index.get(path))
7423        .collect())
7424}
7425
7426fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
7427    let path = parent.join(relative);
7428    let metadata = match std::fs::symlink_metadata(&path) {
7429        Ok(metadata) => metadata,
7430        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7431        Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7432    };
7433    if !metadata.is_dir() {
7434        bail!("the gitlink at {} is not a directory", path.display());
7435    }
7436    let canonical = std::fs::canonicalize(&path)
7437        .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
7438    if canonical != path {
7439        bail!(
7440            "the gitlink at {} resolves through a symlink",
7441            path.display()
7442        );
7443    }
7444    if !path.join(".git").exists() {
7445        let empty = std::fs::read_dir(&path)
7446            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
7447            .next()
7448            .is_none();
7449        if empty {
7450            return Ok(None);
7451        }
7452        bail!(
7453            "the uninitialized gitlink at {} contains local files",
7454            path.display()
7455        );
7456    }
7457    let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
7458    if inside.trim() != "true" {
7459        bail!("the gitlink at {} is not a worktree", path.display());
7460    }
7461    let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
7462    let top = std::fs::canonicalize(top.trim()).map_err(|e| {
7463        spar_err!(
7464            "could not resolve the gitlink top level at {}: {e}",
7465            path.display()
7466        )
7467    })?;
7468    if top != canonical {
7469        bail!(
7470            "the gitlink at {} belongs to a different worktree",
7471            path.display()
7472        );
7473    }
7474    Ok(Some(canonical))
7475}
7476
7477/// A Git entry the outer repository cannot account for.
7478///
7479/// A checkout Git itself reports as a nested repository is expected. Its
7480/// directory is recorded by identity in the untracked snapshot and keeps the
7481/// worktree from being released, so a reviewer that clones a reproduction into
7482/// ignored build output neither loses that clone nor fails the run over it.
7483/// The scan does not descend into one, because what is under it belongs to
7484/// that repository.
7485///
7486/// A Git entry Git does not report is the one worth stopping for. Running
7487/// `git init` inside a directory that already holds tracked files leaves
7488/// nothing in any listing while making every file written under it invisible
7489/// to the outer worktree, so SPAR can no longer prove what is there.
7490fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
7491    let root = std::fs::canonicalize(cwd)
7492        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7493    let mut allowed = BTreeSet::from([root.join(".git")]);
7494    let mut checkouts = BTreeSet::new();
7495    let mut repositories = vec![root.clone()];
7496    let mut visited = BTreeSet::new();
7497    while let Some(repository) = repositories.pop() {
7498        let canonical = std::fs::canonicalize(&repository)
7499            .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7500        if !visited.insert(canonical.clone()) {
7501            bail!("submodule recursion revisited {}", canonical.display());
7502        }
7503        for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
7504            let (path, nested) = untracked_record(&raw, "untracked")?;
7505            if nested {
7506                checkouts.insert(canonical.join(path));
7507            }
7508        }
7509        for link in gitlinks(&canonical)? {
7510            let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
7511                continue;
7512            };
7513            allowed.insert(submodule.join(".git"));
7514            repositories.push(submodule);
7515        }
7516    }
7517
7518    // A build deletes its own directories while this walks them. A directory
7519    // that is gone holds no Git entry, so skipping it answers the question
7520    // being asked; erroring instead ended runs whose worktrees were fine.
7521    let scan_root = root.clone();
7522    let mut directories = vec![root];
7523    while let Some(directory) = directories.pop() {
7524        let entries = match std::fs::read_dir(&directory) {
7525            Ok(entries) => entries,
7526            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7527            Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7528        };
7529        for entry in entries {
7530            let entry = match entry {
7531                Ok(entry) => entry,
7532                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7533                Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7534            };
7535            let path = entry.path();
7536            if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
7537                continue;
7538            }
7539            if entry.file_name() == OsStr::new(".git") {
7540                if !allowed.contains(&path) {
7541                    return Ok(Some(path));
7542                }
7543                continue;
7544            }
7545            let kind = match entry.file_type() {
7546                Ok(kind) => kind,
7547                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7548                Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7549            };
7550            if kind.is_dir() && !checkouts.contains(&path) {
7551                directories.push(path);
7552            }
7553        }
7554    }
7555    Ok(None)
7556}
7557
7558pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
7559    if let Some(path) = unexpected_nested_git_entry(cwd)? {
7560        bail!(
7561            "the worktree contains a Git entry at {} that Git does not report as a nested \
7562             checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
7563             prove they are unchanged.",
7564            path.display()
7565        );
7566    }
7567    let root = std::fs::canonicalize(cwd)
7568        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7569    let mut repositories = BTreeMap::new();
7570    let mut visited = BTreeSet::new();
7571    collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
7572    Ok(GitState { repositories })
7573}
7574
7575fn collect_git_state(
7576    repository: &Path,
7577    prefix: &Path,
7578    visited: &mut BTreeSet<PathBuf>,
7579    repositories: &mut BTreeMap<PathBuf, RepositoryState>,
7580) -> Result<()> {
7581    let canonical = std::fs::canonicalize(repository)
7582        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7583    if !visited.insert(canonical.clone()) {
7584        bail!("submodule recursion revisited {}", canonical.display());
7585    }
7586    let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7587    let head = head.trim().to_string();
7588    if head.is_empty() {
7589        bail!("git returned an empty head for {}", repository.display());
7590    }
7591    let unsafe_index_flags = unsafe_index_flags(repository)?;
7592    let tracked = tracked_entries(repository)?;
7593    let gitlinks = gitlinks(repository)?;
7594    if repositories
7595        .insert(
7596            prefix.to_path_buf(),
7597            RepositoryState {
7598                head,
7599                unsafe_index_flags,
7600                tracked,
7601                gitlinks: gitlinks
7602                    .iter()
7603                    .map(|link| (link.path.clone(), link.oid.clone()))
7604                    .collect(),
7605            },
7606        )
7607        .is_some()
7608    {
7609        bail!("Git state contains duplicate repository path {:?}", prefix);
7610    }
7611
7612    for link in gitlinks {
7613        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7614            continue;
7615        };
7616        collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
7617    }
7618    Ok(())
7619}
7620
7621fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
7622    let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
7623    if !listed.is_empty() && !listed.ends_with(&[0]) {
7624        bail!(
7625            "git returned an unterminated index-flag listing for {}",
7626            cwd.display()
7627        );
7628    }
7629    let mut unsafe_records = Vec::new();
7630    for record in listed
7631        .split(|byte| *byte == 0)
7632        .filter(|record| !record.is_empty())
7633    {
7634        if record.len() < 3 || record[1] != b' ' {
7635            bail!(
7636                "git returned a malformed index-flag record for {}",
7637                cwd.display()
7638            );
7639        }
7640        if record[0] != b'H' {
7641            unsafe_records.extend_from_slice(record);
7642            unsafe_records.push(0);
7643        }
7644    }
7645    Ok(unsafe_records)
7646}
7647
7648pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
7649    safe_git_state(cwd).map(|_| ())
7650}
7651
7652pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
7653    let state = git_state(cwd)?;
7654    if let Some((path, _repository)) = state
7655        .repositories
7656        .iter()
7657        .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
7658    {
7659        let label = if path.as_os_str().is_empty() {
7660            cwd.to_path_buf()
7661        } else {
7662            cwd.join(path)
7663        };
7664        bail!(
7665            "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
7666             SPAR cannot prove the working files are unchanged, so it was kept.",
7667            label.display()
7668        );
7669    }
7670    Ok(state)
7671}
7672
7673fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
7674    repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
7675}
7676
7677fn repository_has_recoverable_work_with(
7678    cwd: &Path,
7679    include_ignored: bool,
7680    disposable: Disposable,
7681) -> Result<bool> {
7682    if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
7683        return Ok(true);
7684    }
7685    let mut visited = BTreeSet::new();
7686    repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
7687}
7688
7689fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
7690    let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
7691    let git_dir = PathBuf::from(git_dir.trim());
7692    let git_dir = if git_dir.is_absolute() {
7693        git_dir
7694    } else {
7695        cwd.join(git_dir)
7696    };
7697    let git_dir = std::fs::canonicalize(&git_dir)
7698        .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
7699    match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
7700        Ok(_) => return Ok(true),
7701        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7702        Err(error) => {
7703            return Err(spar_err!(
7704                "could not inspect per-worktree configuration in {}: {error}",
7705                git_dir.display()
7706            ))
7707        }
7708    }
7709
7710    let orig_head = git_dir.join("ORIG_HEAD");
7711    match std::fs::symlink_metadata(&orig_head) {
7712        Ok(metadata) if metadata.is_file() => {
7713            let oid = std::fs::read_to_string(&orig_head)
7714                .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
7715            let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
7716                return Ok(true);
7717            };
7718            if !commit_has_shared_ref(cwd, &commit)? {
7719                return Ok(true);
7720            }
7721        }
7722        Ok(_) => return Ok(true),
7723        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7724        Err(error) => {
7725            return Err(spar_err!(
7726                "could not inspect {}: {error}",
7727                orig_head.display()
7728            ))
7729        }
7730    }
7731
7732    let edit_message = git_dir.join("COMMIT_EDITMSG");
7733    match std::fs::symlink_metadata(&edit_message) {
7734        Ok(metadata) if metadata.is_file() => {
7735            let draft = std::fs::read(&edit_message)
7736                .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
7737            if draft != head_commit_message(cwd)? {
7738                return Ok(true);
7739            }
7740        }
7741        Ok(_) => return Ok(true),
7742        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7743        Err(error) => {
7744            return Err(spar_err!(
7745                "could not inspect {}: {error}",
7746                edit_message.display()
7747            ))
7748        }
7749    }
7750
7751    if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
7752        return Ok(true);
7753    }
7754
7755    let local_refs = run_git_bytes(
7756        cwd,
7757        &[
7758            "for-each-ref",
7759            "--format=%(refname)",
7760            "refs/worktree",
7761            "refs/bisect",
7762            "refs/rewritten",
7763        ],
7764    )?;
7765    if !local_refs.is_empty() {
7766        return Ok(true);
7767    }
7768
7769    // `FETCH_HEAD` only names what a `git fetch` in this worktree brought
7770    // down. The objects live in the shared store and the next fetch rewrites
7771    // it, so an agent that fetched once must not keep the checkout forever.
7772    for entry in std::fs::read_dir(&git_dir)
7773        .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
7774    {
7775        let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
7776        let known = matches!(
7777            entry.file_name().to_str(),
7778            Some(
7779                "HEAD"
7780                    | "ORIG_HEAD"
7781                    | "COMMIT_EDITMSG"
7782                    | "FETCH_HEAD"
7783                    | "commondir"
7784                    | "gitdir"
7785                    | "index"
7786                    | "logs"
7787                    | "refs"
7788            )
7789        );
7790        if !known {
7791            return Ok(true);
7792        }
7793    }
7794
7795    let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7796    if !commit_has_shared_ref(cwd, head.trim())? {
7797        return Ok(true);
7798    }
7799    Ok(false)
7800}
7801
7802fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
7803    let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
7804    let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
7805        bail!(
7806            "git returned a commit without a message separator in {}",
7807            cwd.display()
7808        );
7809    };
7810    Ok(commit[split + 2..].to_vec())
7811}
7812
7813fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
7814    let metadata = match std::fs::symlink_metadata(logs) {
7815        Ok(metadata) => metadata,
7816        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
7817        Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
7818    };
7819    if !metadata.is_dir() {
7820        return Ok(true);
7821    }
7822    let mut files = Vec::new();
7823    let mut directories = vec![logs.to_path_buf()];
7824    while let Some(directory) = directories.pop() {
7825        for entry in std::fs::read_dir(&directory)
7826            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
7827        {
7828            let entry =
7829                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
7830            let path = entry.path();
7831            let kind = entry
7832                .file_type()
7833                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
7834            if kind.is_dir() {
7835                directories.push(path);
7836            } else if kind.is_file() {
7837                files.push(path);
7838            } else {
7839                return Ok(true);
7840            }
7841        }
7842    }
7843
7844    let mut commits = BTreeSet::new();
7845    for path in files {
7846        if !collect_reflog_commits(cwd, &path, &mut commits)? {
7847            return Ok(true);
7848        }
7849    }
7850    for commit in commits {
7851        if !commit_has_shared_ref(cwd, &commit)? {
7852            return Ok(true);
7853        }
7854    }
7855    Ok(false)
7856}
7857
7858/// Every commit named by one ref's common reflog must survive deletion of that
7859/// ref. Ancestors of a durable current tip survive with the tip; divergent
7860/// entries need another shared ref of their own.
7861fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
7862    let common = common_git_dir(cwd)?;
7863    let reflog = common.join("logs").join(refname);
7864    let metadata = match std::fs::symlink_metadata(&reflog) {
7865        Ok(metadata) => metadata,
7866        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
7867        Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
7868    };
7869    if !metadata.is_file() {
7870        return Ok(false);
7871    }
7872    let mut commits = BTreeSet::new();
7873    if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
7874        return Ok(false);
7875    }
7876    for commit in commits {
7877        if is_ancestor(cwd, &commit, durable_tip)?
7878            || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
7879        {
7880            continue;
7881        }
7882        return Ok(false);
7883    }
7884    Ok(true)
7885}
7886
7887fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
7888    let file = std::fs::File::open(path)
7889        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
7890    for line in std::io::BufReader::new(file).lines() {
7891        let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
7892        let mut fields = line.splitn(3, ' ');
7893        let Some(old) = fields.next() else {
7894            return Ok(false);
7895        };
7896        let Some(new) = fields.next() else {
7897            return Ok(false);
7898        };
7899        if fields.next().is_none() {
7900            return Ok(false);
7901        }
7902        for oid in [old, new] {
7903            if oid.bytes().all(|byte| byte == b'0') {
7904                continue;
7905            }
7906            let Some(commit) = resolve_optional_commit(cwd, oid)? else {
7907                return Ok(false);
7908            };
7909            commits.insert(commit);
7910        }
7911    }
7912    Ok(true)
7913}
7914
7915fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
7916    let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
7917    let path = PathBuf::from(raw.trim());
7918    let path = if path.is_absolute() {
7919        path
7920    } else {
7921        cwd.join(path)
7922    };
7923    std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
7924}
7925
7926fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
7927    let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
7928    let output = proc::exec(
7929        &argv,
7930        &ExecOpts::new()
7931            .cwd(cwd)
7932            .timeout_secs(30)
7933            .check(false)
7934            .stop_descendants(true),
7935    )?;
7936    match output.code {
7937        0 => Ok(true),
7938        1 => Ok(false),
7939        _ => bail!("{}", proc::failure_message(&argv, &output)),
7940    }
7941}
7942
7943fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
7944    let commit = format!("{oid}^{{commit}}");
7945    let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
7946    let output = proc::exec(
7947        &argv,
7948        &ExecOpts::new()
7949            .cwd(cwd)
7950            .timeout_secs(30)
7951            .check(false)
7952            .stop_descendants(true),
7953    )?;
7954    if output.code != 0 {
7955        return Ok(None);
7956    }
7957    let oid = output.stdout.trim();
7958    if oid.is_empty() {
7959        return Ok(None);
7960    }
7961    Ok(Some(oid.to_string()))
7962}
7963
7964fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
7965    commit_has_shared_ref_except(cwd, oid, None)
7966}
7967
7968fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
7969    let contains = format!("--contains={oid}");
7970    let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
7971    Ok(shared.split(|byte| *byte == b'\n').any(|record| {
7972        !record.is_empty()
7973            && !record.starts_with(b"refs/worktree/")
7974            && !record.starts_with(b"refs/bisect/")
7975            && !record.starts_with(b"refs/rewritten/")
7976            && exclude.is_none_or(|excluded| record != excluded.as_bytes())
7977    }))
7978}
7979
7980/// Whether removing the worktree would take away an untracked file somebody
7981/// might want back.
7982///
7983/// Ordinary untracked files always count. So does an ignored file outside the
7984/// known build and cache directories, because an ignored path is only a path
7985/// Git was told not to track, which is where a local `.env` lives as readily as
7986/// compiler output.
7987///
7988/// Recognized build and cache output does not. A managed commit already leaves
7989/// it out rather than treating it as work, and the command that wrote it writes
7990/// it again. Counting it kept every worktree whose tests or build had run,
7991/// which is nearly all of them, so a merged pull request still left its
7992/// checkout behind. A repository nested in that output is somebody else's
7993/// history and counts whatever it sits under.
7994fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
7995    let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
7996    for raw in &ordinary {
7997        let (path, _) = untracked_record(raw, "untracked")?;
7998        if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
7999            continue;
8000        }
8001        return Ok(true);
8002    }
8003    let listed = untracked_listing(
8004        cwd,
8005        &[
8006            "ls-files",
8007            "--others",
8008            "--ignored",
8009            "--exclude-standard",
8010            "-z",
8011        ],
8012    )?;
8013    for raw in listed {
8014        let (path, nested) = untracked_record(&raw, "ignored")?;
8015        // A repository cloned into the output keeps the checkout whatever the
8016        // mode: its objects exist nowhere else, which is the one thing a
8017        // rebuild from origin cannot replace.
8018        if nested {
8019            return Ok(true);
8020        }
8021        if disposable == Disposable::BuildOutput
8022            && !is_generated_artifact(&path)
8023            && !is_desktop_metadata(&path)
8024        {
8025            return Ok(true);
8026        }
8027    }
8028    Ok(false)
8029}
8030
8031fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8032    let listed = run_git_bytes(cwd, args)?;
8033    if !listed.is_empty() && !listed.ends_with(&[0]) {
8034        bail!(
8035            "git returned an unterminated untracked-file list for {}",
8036            cwd.display()
8037        );
8038    }
8039    Ok(listed
8040        .split(|byte| *byte == 0)
8041        .filter(|raw| !raw.is_empty())
8042        .map(|raw| raw.to_vec())
8043        .collect())
8044}
8045
8046fn repository_has_recoverable_work_inner(
8047    cwd: &Path,
8048    include_ignored: bool,
8049    disposable: Disposable,
8050    visited: &mut BTreeSet<PathBuf>,
8051) -> Result<bool> {
8052    let canonical = std::fs::canonicalize(cwd)
8053        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8054    if !visited.insert(canonical.clone()) {
8055        bail!("submodule recursion revisited {}", canonical.display());
8056    }
8057    if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
8058        return Ok(true);
8059    }
8060    if !unsafe_index_flags(cwd)?.is_empty() {
8061        return Ok(true);
8062    }
8063    if attributes_may_be_modified(cwd)? {
8064        return Ok(true);
8065    }
8066    if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
8067        return Ok(true);
8068    }
8069    if include_ignored {
8070        let index = index_entries(cwd)?
8071            .into_iter()
8072            .map(|entry| (entry.path, (entry.mode, entry.oid)))
8073            .collect::<BTreeMap<_, _>>();
8074        let head = tree_entries(cwd, "HEAD")?
8075            .into_iter()
8076            .map(|entry| (entry.path, (entry.mode, entry.oid)))
8077            .collect::<BTreeMap<_, _>>();
8078        if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
8079            return Ok(true);
8080        }
8081        let tracked = tracked_entries(cwd)?;
8082        let effective = check_attributes(cwd, tracked.keys().cloned())?;
8083        let config = CheckoutConfig::read(cwd)?;
8084        for (path, entry) in tracked {
8085            let Some(worktree) = entry.worktree else {
8086                return Ok(true);
8087            };
8088            let attributes = effective.get(&path).ok_or_else(|| {
8089                spar_err!("git omitted attributes for {}", cwd.join(&path).display())
8090            })?;
8091            if path_has_ambiguous_transform(&config, attributes)? {
8092                return Ok(true);
8093            }
8094            let symlink_file = entry.index_mode == "120000"
8095                && worktree.mode == "100644"
8096                && worktree.raw_oid == entry.index_oid
8097                && config.symlinks == Some(false);
8098            if worktree.mode != entry.index_mode && !symlink_file {
8099                return Ok(true);
8100            }
8101            if entry.index_mode == "120000" {
8102                if worktree.raw_oid != entry.index_oid {
8103                    return Ok(true);
8104                }
8105                continue;
8106            }
8107            #[cfg(unix)]
8108            {
8109                let expected = if entry.index_mode == "100755" {
8110                    0o755
8111                } else {
8112                    0o644
8113                };
8114                if worktree.permissions != expected {
8115                    return Ok(true);
8116                }
8117            }
8118            if allows_expected_crlf(&config, attributes)? {
8119                let (normalized, every_lf_was_crlf) =
8120                    normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
8121                if !every_lf_was_crlf || normalized != entry.index_oid {
8122                    return Ok(true);
8123                }
8124            } else if worktree.raw_oid != entry.index_oid {
8125                return Ok(true);
8126            }
8127        }
8128    } else {
8129        let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
8130        if !run_git_bytes(cwd, &args)?.is_empty() {
8131            return Ok(true);
8132        }
8133    }
8134    for link in gitlinks(cwd)? {
8135        let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
8136            continue;
8137        };
8138        // Git stores a linked worktree's initialized submodule objects under
8139        // that worktree's administrative directory. Ordinary removal cannot
8140        // prove a local submodule commit exists anywhere else, even when both
8141        // working trees look clean.
8142        if include_ignored {
8143            return Ok(true);
8144        }
8145        let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8146        if head.trim() != link.oid {
8147            return Ok(true);
8148        }
8149        if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
8150        {
8151            return Ok(true);
8152        }
8153    }
8154    Ok(false)
8155}
8156
8157pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
8158    repository_has_recoverable_work(cwd, false)
8159}
8160
8161fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
8162    let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
8163    Ok(!run_git_bytes(cwd, &args)?.is_empty())
8164}
8165
8166#[cfg(unix)]
8167fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8168    use std::os::unix::ffi::OsStringExt;
8169    Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
8170}
8171
8172#[cfg(not(unix))]
8173fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8174    String::from_utf8(raw.to_vec())
8175        .map(PathBuf::from)
8176        .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
8177}
8178
8179/// Fingerprint the directory of a nested repository without reading inside it.
8180///
8181/// The files under it are that repository's, not this one's. They are recorded
8182/// against its own baseline whenever SPAR works there, and a run of its own may
8183/// legitimately add or remove entries while this call is in flight, so the
8184/// volatile directory fields stay out of the fingerprint. Identity and type
8185/// remain, which is what makes deleting the checkout, or replacing it with a
8186/// file, observable from the outer worktree.
8187/// What a path Git listed looks like once it is no longer there.
8188///
8189/// A reviewer building the project deletes and rewrites its output constantly,
8190/// so a path can go between the listing that named it and the stat that
8191/// measures it. Treating that as an unreadable worktree ended runs that had
8192/// nothing wrong with them. The record stays in the snapshot rather than being
8193/// dropped, so a path that vanishes cannot pass for one that was never there.
8194fn vanished_untracked_file() -> UntrackedFile {
8195    UntrackedFile {
8196        kind: 0,
8197        len: 0,
8198        modified: None,
8199        created: None,
8200        readonly: false,
8201        symlink_target: None,
8202        #[cfg(unix)]
8203        device: 0,
8204        #[cfg(unix)]
8205        inode: 0,
8206        #[cfg(unix)]
8207        mode: 0,
8208        #[cfg(unix)]
8209        change_seconds: 0,
8210        #[cfg(unix)]
8211        change_nanoseconds: 0,
8212    }
8213}
8214
8215fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
8216    let metadata = match std::fs::symlink_metadata(path) {
8217        Ok(metadata) => metadata,
8218        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8219        Err(e) => {
8220            return Err(spar_err!(
8221                "could not inspect the nested repository at {}: {e}",
8222                path.display()
8223            ))
8224        }
8225    };
8226    if !metadata.is_dir() {
8227        bail!(
8228            "git reported {} as a nested repository, but it is not a directory",
8229            path.display()
8230        );
8231    }
8232    if std::fs::symlink_metadata(path.join(".git")).is_err() {
8233        bail!(
8234            "git reported {} as a nested repository, but it has no Git entry",
8235            path.display()
8236        );
8237    }
8238    #[cfg(unix)]
8239    {
8240        use std::os::unix::fs::MetadataExt;
8241        Ok(UntrackedFile {
8242            kind: 3,
8243            len: 0,
8244            modified: None,
8245            created: metadata.created().ok(),
8246            readonly: metadata.permissions().readonly(),
8247            symlink_target: None,
8248            device: metadata.dev(),
8249            inode: metadata.ino(),
8250            mode: metadata.mode(),
8251            change_seconds: 0,
8252            change_nanoseconds: 0,
8253        })
8254    }
8255    #[cfg(not(unix))]
8256    {
8257        Ok(UntrackedFile {
8258            kind: 3,
8259            len: 0,
8260            modified: None,
8261            created: metadata.created().ok(),
8262            readonly: metadata.permissions().readonly(),
8263            symlink_target: None,
8264        })
8265    }
8266}
8267
8268fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
8269    let metadata = match std::fs::symlink_metadata(path) {
8270        Ok(metadata) => metadata,
8271        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8272        Err(e) => {
8273            return Err(spar_err!(
8274                "could not inspect untracked file {}: {e}",
8275                path.display()
8276            ))
8277        }
8278    };
8279    let kind = if metadata.file_type().is_symlink() {
8280        2
8281    } else if metadata.is_file() {
8282        1
8283    } else {
8284        bail!(
8285            "untracked path {} is not a regular file or symlink",
8286            path.display()
8287        );
8288    };
8289    let symlink_target = if kind == 2 {
8290        match std::fs::read_link(path) {
8291            Ok(target) => Some(os_str_bytes(target.as_os_str())?),
8292            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
8293                return Ok(vanished_untracked_file())
8294            }
8295            Err(e) => {
8296                return Err(spar_err!(
8297                    "could not read untracked symlink {}: {e}",
8298                    path.display()
8299                ))
8300            }
8301        }
8302    } else {
8303        None
8304    };
8305    #[cfg(unix)]
8306    {
8307        use std::os::unix::fs::MetadataExt;
8308        Ok(UntrackedFile {
8309            kind,
8310            len: metadata.len(),
8311            modified: metadata.modified().ok(),
8312            created: metadata.created().ok(),
8313            readonly: metadata.permissions().readonly(),
8314            symlink_target,
8315            device: metadata.dev(),
8316            inode: metadata.ino(),
8317            mode: metadata.mode(),
8318            change_seconds: metadata.ctime(),
8319            change_nanoseconds: metadata.ctime_nsec(),
8320        })
8321    }
8322    #[cfg(not(unix))]
8323    {
8324        Ok(UntrackedFile {
8325            kind,
8326            len: metadata.len(),
8327            modified: metadata.modified().ok(),
8328            created: metadata.created().ok(),
8329            readonly: metadata.permissions().readonly(),
8330            symlink_target,
8331        })
8332    }
8333}
8334
8335#[cfg(unix)]
8336fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8337    use std::os::unix::ffi::OsStrExt;
8338    Ok(value.as_bytes().to_vec())
8339}
8340
8341#[cfg(not(unix))]
8342fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8343    value
8344        .to_str()
8345        .map(|value| value.as_bytes().to_vec())
8346        .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
8347}
8348
8349#[cfg(unix)]
8350fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8351    use std::os::unix::fs::MetadataExt;
8352    right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
8353}
8354
8355#[cfg(not(unix))]
8356fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8357    right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
8358}
8359
8360#[derive(Debug, Clone, serde::Serialize, Deserialize)]
8361pub struct BranchRecord {
8362    pub kind: String,
8363    pub number: i64,
8364}
8365
8366/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
8367/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
8368pub fn review_ref(number: i64) -> String {
8369    format!("refs/spar/pr-{number}")
8370}
8371
8372pub fn is_finished(state: &str) -> bool {
8373    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
8374}
8375
8376/// Write text through a temporary file and rename, so a kill cannot leave a
8377/// truncated file behind.
8378///
8379/// The follow-up queue is the one file spar rewrites in place rather than
8380/// appends to, and a truncated queue is lost work: what it held was never
8381/// written anywhere else.
8382pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
8383    if let Some(parent) = path.parent() {
8384        std::fs::create_dir_all(parent)
8385            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
8386    }
8387    // The extension defaults to `json` so `clear_state`, which removes a
8388    // leftover `pr-N.json.tmp` by name, keeps finding the one this wrote.
8389    let tmp = path.with_extension(format!(
8390        "{}.tmp",
8391        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
8392    ));
8393    std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
8394    std::fs::rename(&tmp, path)
8395        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
8396    Ok(())
8397}
8398
8399/// Whether a follow-up file already has an entry under this exact title.
8400///
8401/// Whole heading lines, case insensitively. A substring check answered yes
8402/// for "Retry loop" against "## Retry loop never terminates", which is a
8403/// different defect, and the new one was dropped with a line saying it was
8404/// already noted.
8405pub fn has_heading(text: &str, title: &str) -> bool {
8406    let wanted = title.trim().to_lowercase();
8407    if wanted.is_empty() {
8408        return false;
8409    }
8410    text.lines()
8411        .filter_map(|line| line.trim().strip_prefix("## "))
8412        .any(|heading| heading.trim().to_lowercase() == wanted)
8413}
8414
8415/// Write JSON through a temporary file and rename, so a kill cannot leave a
8416/// truncated state file behind.
8417pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
8418    write_text_atomic(path, &serde_json::to_string_pretty(value)?)
8419}
8420
8421/// One row of `gh pr list --json number,url,title,baseRefName,closingIssuesReferences`.
8422#[derive(Deserialize)]
8423#[serde(rename_all = "camelCase")]
8424pub struct LinkedPrRow {
8425    number: i64,
8426    #[serde(default)]
8427    url: String,
8428    #[serde(default)]
8429    title: String,
8430    #[serde(default)]
8431    closing_issues_references: Vec<IssueRef>,
8432}
8433
8434/// The listed pull requests, or `None` when the payload could not be read.
8435///
8436/// Separated from the gh call so the real payload shape can be tested, and kept
8437/// distinct from an empty list, because silently failing to parse would look
8438/// exactly like "no pull request exists", which is the answer that makes spar
8439/// implement over the top of somebody's work.
8440pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
8441    serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
8442}
8443
8444/// Among the listed pull requests, the first that would close `issue`.
8445pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
8446    rows.iter()
8447        .find(|row| {
8448            row.closing_issues_references
8449                .iter()
8450                .any(|linked| linked.number == issue)
8451        })
8452        .map(|row| PrRef {
8453            number: row.number,
8454            url: row.url.clone(),
8455            title: row.title.clone(),
8456        })
8457}
8458
8459/// Both halves together, for the tests that hold the real payload.
8460pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
8461    pick_linked_pr(&linked_pr_rows(json)?, issue)
8462}
8463
8464/// Flatten whatever `gh api --paginate` printed into a list of comments.
8465///
8466/// Current gh merges array pages into one array. Older builds concatenated one
8467/// document per page. A streaming parser reads either, and unlike splitting the
8468/// text on a bracket pair it cannot be fooled by a comment body that happens to
8469/// contain one, which would otherwise make a resume silently start over.
8470pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
8471    if text.trim().is_empty() {
8472        return Err(spar_err!("GitHub returned no comment data"));
8473    }
8474    let mut out = Vec::new();
8475    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8476        match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
8477            Value::Array(items) => out.extend(items),
8478            _ => return Err(spar_err!("unexpected non-array comment page")),
8479        }
8480    }
8481    Ok(out)
8482}
8483
8484pub fn parse_comment_pages(text: &str) -> Vec<Value> {
8485    let mut out = Vec::new();
8486    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8487        match value {
8488            Ok(Value::Array(items)) => out.extend(items),
8489            Ok(other) => out.push(other),
8490            Err(_) => break,
8491        }
8492    }
8493    out
8494}
8495
8496/// Whether a comment is a state block spar itself wrote.
8497///
8498/// The body has to open with the marker, so a person quoting it in a sentence
8499/// is not one; it has to parse, so a half-quoted block is not one; and it has
8500/// to be the viewer's own comment, the way `comments.rs` already tells spar's
8501/// writes apart. Anything less and `write_pr_state` patches somebody's comment
8502/// with JSON, and `spar clean --pr-state` later deletes it.
8503pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
8504    body.trim_start().starts_with(STATE_MARKER)
8505        && !viewer.trim().is_empty()
8506        && author.trim().eq_ignore_ascii_case(viewer.trim())
8507        && parse_state_comment(body).is_some()
8508}
8509
8510/// Extract the payload from a state comment. The marker is followed by JSON and
8511/// terminated with `-->`.
8512pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
8513    let marker = body.find(STATE_MARKER)?;
8514    let start = body[marker..].find('{')? + marker;
8515    let end = body.rfind('}')?;
8516    if end <= start {
8517        return None;
8518    }
8519    match serde_json::from_str(&body[start..=end]) {
8520        Ok(state) => Some(state),
8521        Err(_) => {
8522            logdim!("found a spar state comment but could not parse it");
8523            None
8524        }
8525    }
8526}
8527
8528fn choose_state_for_head(
8529    candidates: Vec<PersistedState>,
8530    actual_head: &str,
8531) -> Option<PersistedState> {
8532    let matching: Vec<PersistedState> = candidates
8533        .iter()
8534        .filter(|state| state.pr_head == actual_head)
8535        .cloned()
8536        .collect();
8537    if !matching.is_empty() {
8538        return newest_state(matching);
8539    }
8540    newest_state(candidates)
8541}
8542
8543fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
8544    candidates.into_iter().reduce(|best, candidate| {
8545        if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
8546            candidate
8547        } else {
8548            // The local candidate is supplied first. Keeping the first exact
8549            // tie recovers correctly from a local write followed by a failed
8550            // pull request state update, including legacy states with no
8551            // checkpoint field.
8552            best
8553        }
8554    })
8555}
8556
8557/// Where this binary lives, so `git filter-branch` can call back into it.
8558///
8559/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
8560/// tests, whose `current_exe` is the test harness rather than spar, and for
8561/// anyone who ships spar behind a wrapper script.
8562pub fn self_binary() -> Result<PathBuf> {
8563    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
8564        let path = PathBuf::from(path);
8565        if proc::is_executable(&path) {
8566            return Ok(path);
8567        }
8568        bail!(
8569            "SPAR_SELF_BIN is set to {}, which is not executable",
8570            path.display()
8571        );
8572    }
8573    std::env::current_exe()
8574        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
8575}
8576
8577fn bool_env(value: bool) -> &'static str {
8578    if value {
8579        "1"
8580    } else {
8581        "0"
8582    }
8583}
8584
8585/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
8586/// shell command, and an install path with a space in it is not exotic.
8587pub fn sh_quote(text: &str) -> String {
8588    format!("'{}'", text.replace('\'', r"'\''"))
8589}
8590
8591/// Style rules for the `scrub-filter` subcommand, which runs in a child process
8592/// spawned by git and so cannot see the parent's config.
8593pub fn style_from_env() -> Style {
8594    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
8595    Style {
8596        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
8597        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
8598        ..Style::permissive()
8599    }
8600}
8601
8602#[cfg(test)]
8603mod tests {
8604    use super::*;
8605    use crate::config::StateStore;
8606    use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
8607    use std::process::Command;
8608
8609    fn repo_for_titles() -> Repo {
8610        Repo {
8611            root: PathBuf::from("/nonexistent"),
8612            style: Style::default(),
8613            branch_prefix: String::new(),
8614            state_store: StateStore::Local,
8615            followups: crate::config::Followups::Issues,
8616            drafts: Drafts::Never,
8617            fork_when_denied: true,
8618            fork: OnceLock::new(),
8619            viewer: OnceLock::new(),
8620            checkpoints: Mutex::new(BTreeMap::new()),
8621            writes: WriteStats::default(),
8622        }
8623    }
8624
8625    #[test]
8626    fn write_results_accumulate_for_the_run() {
8627        let repo = repo_for_titles();
8628
8629        let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
8630        let _: std::result::Result<(), ()> = repo.record_write(Err(()));
8631
8632        assert_eq!(
8633            WriteSummary {
8634                attempted: 2,
8635                failed: 1,
8636            },
8637            repo.write_summary()
8638        );
8639    }
8640
8641    #[test]
8642    fn only_failed_write_preflights_join_the_summary() {
8643        let repo = repo_for_titles();
8644
8645        let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
8646        let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
8647
8648        assert_eq!(
8649            WriteSummary {
8650                attempted: 1,
8651                failed: 1,
8652            },
8653            repo.write_summary()
8654        );
8655    }
8656
8657    #[test]
8658    fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
8659        let repo = repo_for_titles();
8660
8661        assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
8662        assert_eq!(
8663            WriteSummary {
8664                attempted: 1,
8665                failed: 1,
8666            },
8667            repo.write_summary()
8668        );
8669    }
8670
8671    #[test]
8672    fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
8673        let mut repo = repo_for_titles();
8674        repo.followups = Followups::Local;
8675
8676        assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
8677        assert_eq!(WriteSummary::default(), repo.write_summary());
8678    }
8679
8680    #[test]
8681    fn a_failed_remote_state_read_stops_before_state_mutation() {
8682        let root = std::env::temp_dir().join(format!(
8683            "spar-state-preflight-{}-{}",
8684            std::process::id(),
8685            std::time::SystemTime::now()
8686                .duration_since(std::time::UNIX_EPOCH)
8687                .unwrap()
8688                .as_nanos()
8689        ));
8690        std::fs::create_dir_all(&root).unwrap();
8691        let _fixture = ReviewFixture { root: root.clone() };
8692        let mut repo = repo_for_titles();
8693        repo.root = root;
8694        repo.state_store = StateStore::Both;
8695        let state = PersistedState {
8696            version: 1,
8697            checkpoint: 4,
8698            round: 2,
8699            next_actor: "a".into(),
8700            status: Status::Pending,
8701            pr_head: "abc123".into(),
8702            ledger: Ledger::new(),
8703            filed: Vec::new(),
8704            open_findings: Vec::new(),
8705            disputes: Vec::new(),
8706            noted: Vec::new(),
8707        };
8708
8709        let error = repo
8710            .write_state_after_remote_read(
8711                7,
8712                &state,
8713                Err(crate::error::SparError::new("state comments unavailable")),
8714            )
8715            .unwrap_err();
8716
8717        assert!(error.to_string().contains("state comments unavailable"));
8718        assert!(!repo.state_path(7).exists());
8719        assert_eq!(0, repo.remembered_checkpoint(7));
8720        assert_eq!(
8721            WriteSummary {
8722                attempted: 1,
8723                failed: 1,
8724            },
8725            repo.write_summary()
8726        );
8727    }
8728
8729    #[test]
8730    fn only_known_build_and_cache_directories_are_generated_artifacts() {
8731        assert!(is_generated_artifact(Path::new("target/debug/artifact")));
8732        assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
8733        assert!(is_generated_artifact(Path::new(
8734            "package/node_modules/dependency/file.js"
8735        )));
8736        assert!(!is_generated_artifact(Path::new(
8737            "distribution/required-package.js"
8738        )));
8739        assert!(!is_generated_artifact(Path::new(
8740            "generated/required-fixture.txt"
8741        )));
8742        assert!(!is_generated_artifact(Path::new("local.env")));
8743    }
8744
8745    #[test]
8746    fn only_finder_state_files_are_desktop_metadata() {
8747        assert!(is_desktop_metadata(Path::new(".DS_Store")));
8748        assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
8749        assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
8750        assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
8751    }
8752
8753    struct ReviewFixture {
8754        root: PathBuf,
8755    }
8756
8757    impl Drop for ReviewFixture {
8758        fn drop(&mut self) {
8759            let _ = std::fs::remove_dir_all(&self.root);
8760        }
8761    }
8762
8763    fn test_git(cwd: &Path, args: &[&str]) -> String {
8764        let output = Command::new("git")
8765            .args(args)
8766            .current_dir(cwd)
8767            .output()
8768            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
8769        assert!(
8770            output.status.success(),
8771            "git {args:?} failed: {}",
8772            String::from_utf8_lossy(&output.stderr)
8773        );
8774        String::from_utf8_lossy(&output.stdout).into_owned()
8775    }
8776
8777    fn review_fixture(
8778        tag: &str,
8779        number: i64,
8780    ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
8781        use std::sync::atomic::{AtomicU32, Ordering};
8782        static NEXT: AtomicU32 = AtomicU32::new(0);
8783        let id = NEXT.fetch_add(1, Ordering::Relaxed);
8784        let root =
8785            std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
8786        let origin = root.join("origin.git");
8787        let work = root.join("work");
8788        std::fs::create_dir_all(&origin).unwrap();
8789        std::fs::create_dir_all(&work).unwrap();
8790        test_git(&origin, &["init", "--bare", "-b", "main"]);
8791        test_git(&work, &["init", "-b", "main"]);
8792        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
8793        test_git(&work, &["config", "user.name", "spar test"]);
8794        test_git(&work, &["config", "commit.gpgsign", "false"]);
8795        test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
8796        test_git(&work, &["config", "filter.drop.smudge", "cat"]);
8797        std::fs::write(work.join("README.md"), "seed\n").unwrap();
8798        std::fs::write(work.join("data.txt"), "old\n").unwrap();
8799        std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
8800        std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
8801        test_git(&work, &["add", "."]);
8802        test_git(&work, &["commit", "-m", "seed"]);
8803        test_git(
8804            &work,
8805            &["remote", "add", "origin", origin.to_str().unwrap()],
8806        );
8807        test_git(&work, &["push", "-u", "origin", "main"]);
8808        test_git(
8809            &work,
8810            &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
8811        );
8812        let cfg = crate::config::parse(
8813            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
8814        )
8815        .unwrap();
8816        let repo = Repo::open(&work, &cfg).unwrap();
8817        let path = repo.worktree_for_pr_head(number).unwrap();
8818        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8819        (ReviewFixture { root }, repo, path, checkpoint)
8820    }
8821
8822    #[test]
8823    fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
8824        let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
8825
8826        repo.release_review_worktree_checked(901, &checkpoint)
8827            .unwrap();
8828
8829        assert!(!path.exists());
8830    }
8831
8832    #[test]
8833    fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
8834        let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
8835        let work = fixture.root.join("work");
8836        let seed = test_git(&work, &["rev-parse", "HEAD"]);
8837        let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
8838        // Heads only the pull request holds, the second rewritten rather than
8839        // built on the first, as a rebase and force push leave them.
8840        let publish = |message: &str| {
8841            let oid = test_git(
8842                &work,
8843                &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
8844            );
8845            let oid = oid.trim().to_string();
8846            test_git(
8847                &work,
8848                &[
8849                    "push",
8850                    "--force",
8851                    "origin",
8852                    &format!("{oid}:refs/pull/923/head"),
8853                ],
8854            );
8855            oid
8856        };
8857        let reviewed = publish("reviewed head");
8858        repo.worktree_for_pr_head(923).unwrap();
8859        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
8860
8861        let rewritten = publish("rewritten head");
8862        repo.worktree_for_pr_head(923).unwrap();
8863
8864        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
8865    }
8866
8867    #[test]
8868    fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
8869        let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
8870        let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
8871        std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
8872        test_git(&path, &["add", "recovery.txt"]);
8873        test_git(&path, &["commit", "-m", "recovery commit"]);
8874        let recovery = test_git(&path, &["rev-parse", "HEAD"]);
8875        test_git(&path, &["reset", "--hard", "main"]);
8876
8877        assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
8878        test_git(
8879            &path,
8880            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
8881        );
8882    }
8883
8884    #[test]
8885    fn a_review_ref_reflog_only_commit_prevents_deletion() {
8886        let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
8887        let local_ref = review_ref(921);
8888        let original = test_git(&path, &["rev-parse", &local_ref]);
8889        let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
8890        let recovery = test_git(
8891            &path,
8892            &[
8893                "commit-tree",
8894                tree.trim(),
8895                "-p",
8896                original.trim(),
8897                "-m",
8898                "review ref recovery",
8899            ],
8900        );
8901        test_git(
8902            &path,
8903            &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
8904        );
8905        test_git(
8906            &path,
8907            &["update-ref", &local_ref, original.trim(), recovery.trim()],
8908        );
8909
8910        assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
8911        assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
8912        test_git(
8913            &path,
8914            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
8915        );
8916    }
8917
8918    #[test]
8919    fn an_unpublished_commit_message_draft_is_recoverable() {
8920        let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
8921        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
8922        let git_dir = if raw.is_absolute() {
8923            raw
8924        } else {
8925            path.join(raw)
8926        };
8927        std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
8928
8929        assert!(repository_has_recoverable_work(&path, true).unwrap());
8930        assert_eq!(
8931            "unique recovery draft\n",
8932            std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
8933        );
8934    }
8935
8936    #[test]
8937    fn a_changed_review_worktree_is_retained_after_a_checked_read() {
8938        let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
8939        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
8940
8941        let error = repo
8942            .release_review_worktree_checked(902, &checkpoint)
8943            .unwrap_err();
8944
8945        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8946        assert!(error.to_string().contains("kept for recovery"), "{error}");
8947        assert_eq!(
8948            "recover me\n",
8949            std::fs::read_to_string(path.join("README.md")).unwrap()
8950        );
8951        repo.release_review_worktree(902);
8952    }
8953
8954    #[test]
8955    fn a_review_commit_is_retained_after_a_checked_read() {
8956        let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
8957        std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
8958        test_git(&path, &["add", "review-note.txt"]);
8959        test_git(&path, &["commit", "-m", "local review recovery"]);
8960        let head = test_git(&path, &["rev-parse", "HEAD"]);
8961
8962        let error = repo
8963            .release_review_worktree_checked(903, &checkpoint)
8964            .unwrap_err();
8965
8966        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8967        assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
8968        assert_eq!(
8969            "recover me\n",
8970            std::fs::read_to_string(path.join("review-note.txt")).unwrap()
8971        );
8972        repo.release_review_worktree(903);
8973    }
8974
8975    /// The read is no longer what refuses: a file the call created and Git was
8976    /// told to ignore is its own output. Removal still refuses, so the file is
8977    /// there to look at, which is the part that mattered.
8978    #[test]
8979    fn an_ignored_review_file_is_retained_after_a_checked_read() {
8980        let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
8981        std::fs::create_dir_all(path.join("generated")).unwrap();
8982        std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
8983
8984        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8985            .expect("its own output is not a change");
8986        let error = repo
8987            .release_review_worktree_checked(904, &checkpoint)
8988            .unwrap_err();
8989
8990        assert!(error.to_string().contains("recoverable"), "{error}");
8991        assert_eq!(
8992            "recover me\n",
8993            std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
8994        );
8995        repo.release_review_worktree(904);
8996    }
8997
8998    #[test]
8999    /// The checkpoint no longer refuses this: a path the project ignores is out
9000    /// of scope for a call that only read. What keeps the file is the gate that
9001    /// actually deletes the checkout, which refuses any ignored file outside a
9002    /// known build directory. That is the check with the stake in it, and it is
9003    /// the one that has to hold.
9004    fn a_preexisting_ignored_review_file_change_is_retained() {
9005        let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
9006        std::fs::create_dir_all(path.join("generated")).unwrap();
9007        let ignored = path.join("generated/recovery.txt");
9008        std::fs::write(&ignored, "before\n").unwrap();
9009        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9010        std::fs::write(&ignored, "after!\n").unwrap();
9011
9012        let error = repo
9013            .release_review_worktree_checked(905, &checkpoint)
9014            .unwrap_err();
9015
9016        assert!(error.to_string().contains("recoverable"), "{error}");
9017        assert!(path.exists());
9018        assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
9019        repo.release_review_worktree(905);
9020    }
9021
9022    #[test]
9023    fn a_preexisting_ignored_review_file_prevents_checked_removal() {
9024        let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
9025        std::fs::create_dir_all(path.join("generated")).unwrap();
9026        let ignored = path.join("generated/recovery.txt");
9027        std::fs::write(&ignored, "keep me\n").unwrap();
9028        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9029
9030        let error = repo
9031            .release_review_worktree_checked(906, &checkpoint)
9032            .unwrap_err();
9033
9034        assert!(error.to_string().contains("recoverable"), "{error}");
9035        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
9036    }
9037
9038    #[test]
9039    fn overwriting_a_preexisting_untracked_file_is_detected() {
9040        let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
9041        let untracked = path.join("notes.txt");
9042        std::fs::write(&untracked, "before\n").unwrap();
9043        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9044        std::fs::write(&untracked, "after!\n").unwrap();
9045
9046        let error = repo
9047            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9048            .unwrap_err();
9049
9050        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9051        assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
9052    }
9053
9054    #[test]
9055    fn an_assume_unchanged_edit_is_detected() {
9056        let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
9057        test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
9058        std::fs::write(path.join("README.md"), "hidden\n").unwrap();
9059
9060        let error = repo
9061            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9062            .unwrap_err();
9063
9064        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9065        assert_eq!(
9066            "hidden\n",
9067            std::fs::read_to_string(path.join("README.md")).unwrap()
9068        );
9069    }
9070
9071    #[test]
9072    fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
9073        let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
9074        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9075        test_git(&path, &["add", "README.md"]);
9076        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9077
9078        let error = repo
9079            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9080            .unwrap_err();
9081
9082        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9083        assert_eq!(
9084            b"seed\r\n",
9085            std::fs::read(path.join("README.md")).unwrap().as_slice()
9086        );
9087    }
9088
9089    #[cfg(unix)]
9090    #[test]
9091    fn a_mode_edit_is_detected_when_filemode_is_disabled() {
9092        use std::os::unix::fs::PermissionsExt;
9093
9094        let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
9095        test_git(&path, &["config", "core.filemode", "false"]);
9096        let readme = path.join("README.md");
9097        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9098        permissions.set_mode(0o755);
9099        std::fs::set_permissions(&readme, permissions).unwrap();
9100        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9101
9102        let error = repo
9103            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9104            .unwrap_err();
9105
9106        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9107        assert_eq!(
9108            0o755,
9109            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9110        );
9111    }
9112
9113    #[test]
9114    fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
9115        let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
9116        std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
9117        test_git(&path, &["add", ".gitattributes"]);
9118        test_git(&path, &["commit", "-m", "select data filter"]);
9119        let baseline = repo.worktree_baseline(&path).unwrap();
9120        std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
9121
9122        assert!(repo
9123            .commit_pending_changes(&path, &baseline, "change data", "change data")
9124            .unwrap());
9125        let error = repo
9126            .refuse_unrepresented_tracked_changes(&path, &baseline)
9127            .unwrap_err();
9128
9129        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9130        assert_eq!(
9131            "secret: recover me\nnew\n",
9132            std::fs::read_to_string(path.join("data.txt")).unwrap()
9133        );
9134        assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
9135    }
9136
9137    #[test]
9138    fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
9139        let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
9140        std::fs::create_dir_all(path.join("target")).unwrap();
9141        let untracked = path.join("target/user.yaml");
9142        std::fs::write(&untracked, "user data\n").unwrap();
9143        let baseline = repo.worktree_baseline(&path).unwrap();
9144        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9145
9146        assert!(repo
9147            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9148            .unwrap());
9149
9150        assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
9151        assert_eq!(
9152            "?? target/user.yaml\n",
9153            test_git(&path, &["status", "--short", "--untracked-files=all"])
9154        );
9155        assert!(test_git(
9156            &path,
9157            &[
9158                "ls-tree",
9159                "-r",
9160                "--name-only",
9161                "HEAD",
9162                "--",
9163                "target/user.yaml"
9164            ]
9165        )
9166        .is_empty());
9167    }
9168
9169    #[test]
9170    fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
9171        let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
9172        std::fs::create_dir_all(path.join("target")).unwrap();
9173        let untracked = path.join("target/user.yaml");
9174        std::fs::write(&untracked, "before\n").unwrap();
9175        let baseline = repo.worktree_baseline(&path).unwrap();
9176        let before = test_git(&path, &["rev-parse", "HEAD"]);
9177        std::fs::write(&untracked, "after\n").unwrap();
9178        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9179
9180        let error = repo
9181            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9182            .unwrap_err();
9183
9184        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9185        assert!(error.to_string().contains("target/user.yaml"), "{error}");
9186        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
9187        assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
9188        assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
9189    }
9190
9191    #[test]
9192    fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
9193        let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
9194        let baseline = repo.worktree_baseline(&path).unwrap();
9195        std::fs::create_dir_all(path.join("target")).unwrap();
9196        std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
9197
9198        assert!(repo
9199            .commit_pending_changes(&path, &baseline, "add file", "add file")
9200            .unwrap());
9201
9202        assert_eq!(
9203            "new file\n",
9204            test_git(&path, &["show", "HEAD:target/new.txt"])
9205        );
9206        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9207    }
9208
9209    #[test]
9210    fn a_managed_commit_accepts_a_custom_output_rebuild() {
9211        let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
9212        exclude_paths(&repo, &["manager/public/"]);
9213        let output = path.join("manager/public/assets");
9214        std::fs::create_dir_all(&output).unwrap();
9215        std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
9216        std::fs::write(output.join("index.css"), "old styles\n").unwrap();
9217        let baseline = repo.worktree_baseline(&path).unwrap();
9218        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9219        std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
9220        std::fs::remove_file(output.join("old.js")).unwrap();
9221        std::fs::write(output.join("index.css"), "new styles\n").unwrap();
9222        std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
9223
9224        assert!(repo
9225            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9226            .unwrap());
9227        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9228        repo.refuse_changed_existing_untracked(&path, &baseline)
9229            .unwrap();
9230        repo.refuse_unrepresented_tracked_changes(&path, &baseline)
9231            .unwrap();
9232
9233        assert_eq!(
9234            "tracked change\n",
9235            test_git(&path, &["show", "HEAD:README.md"])
9236        );
9237        assert_eq!(
9238            "new source\n",
9239            test_git(&path, &["show", "HEAD:new-source.txt"])
9240        );
9241        assert!(test_git(
9242            &path,
9243            &[
9244                "ls-tree",
9245                "-r",
9246                "--name-only",
9247                "HEAD",
9248                "--",
9249                "manager/public"
9250            ]
9251        )
9252        .is_empty());
9253        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9254        assert!(!output.join("old.js").exists());
9255        assert_eq!(
9256            "new styles\n",
9257            std::fs::read_to_string(output.join("index.css")).unwrap()
9258        );
9259        assert!(repository_has_recoverable_work(&path, true).unwrap());
9260        repo.release_review_worktree(912);
9261        assert!(output.join("index-DUym-Rfj.js").exists());
9262    }
9263
9264    #[test]
9265    fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
9266        let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
9267        std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
9268        let baseline = repo.worktree_baseline(&path).unwrap();
9269        std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
9270        std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
9271
9272        let error = repo
9273            .commit_pending_changes(&path, &baseline, "change", "change")
9274            .unwrap_err();
9275
9276        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9277        assert!(error.to_string().contains("notes.txt"), "{error}");
9278    }
9279
9280    #[test]
9281    fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
9282        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
9283        let baseline = repo.worktree_baseline(&path).unwrap();
9284        let nested = path.join("generated/repro");
9285        std::fs::create_dir_all(&nested).unwrap();
9286        test_git(&nested, &["init"]);
9287        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9288
9289        assert!(repo
9290            .commit_pending_changes(&path, &baseline, "change", "change")
9291            .unwrap());
9292        assert!(!repo.has_uncommitted_changes(&path).unwrap());
9293        assert!(repository_has_recoverable_work(&path, true).unwrap());
9294        repo.release_review_worktree(927);
9295        assert!(nested.join(".git").exists());
9296    }
9297
9298    #[test]
9299    fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
9300        let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
9301        test_git(&path, &["config", "core.autocrlf", "true"]);
9302        std::fs::write(path.join("README.md"), "seed\n").unwrap();
9303        assert_eq!(
9304            test_git(&path, &["hash-object", "README.md"]).trim(),
9305            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9306        );
9307
9308        assert!(repository_has_recoverable_work(&path, true).unwrap());
9309        assert_eq!(
9310            "seed\n",
9311            std::fs::read_to_string(path.join("README.md")).unwrap()
9312        );
9313    }
9314
9315    #[test]
9316    fn autocrlf_input_overrides_a_crlf_core_eol() {
9317        let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
9318        test_git(&path, &["config", "core.autocrlf", "input"]);
9319        test_git(&path, &["config", "core.eol", "crlf"]);
9320        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9321        assert_eq!(
9322            test_git(&path, &["hash-object", "README.md"]).trim(),
9323            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9324        );
9325
9326        assert!(repository_has_recoverable_work(&path, true).unwrap());
9327        assert_eq!(
9328            b"seed\r\n",
9329            std::fs::read(path.join("README.md")).unwrap().as_slice()
9330        );
9331    }
9332
9333    #[cfg(unix)]
9334    #[test]
9335    fn a_non_executable_permission_change_is_recoverable() {
9336        use std::os::unix::fs::PermissionsExt;
9337
9338        let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
9339        let readme = path.join("README.md");
9340        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9341        permissions.set_mode(0o600);
9342        std::fs::set_permissions(&readme, permissions).unwrap();
9343        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9344
9345        let error = repo
9346            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9347            .unwrap_err();
9348
9349        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9350        assert!(repository_has_recoverable_work(&path, true).unwrap());
9351        assert_eq!(
9352            0o600,
9353            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9354        );
9355    }
9356
9357    #[cfg(unix)]
9358    #[test]
9359    fn a_managed_commit_skips_signing_and_hooks() {
9360        use std::os::unix::fs::PermissionsExt;
9361
9362        let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
9363        let common = common_git_dir(&path).unwrap();
9364        let hook = common.join("hooks/pre-commit");
9365        let marker = fixture.root.join("hook-ran");
9366        std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
9367        std::fs::write(
9368            &hook,
9369            format!(
9370                "#!/bin/sh\nprintf ran > {}\nexit 1\n",
9371                sh_quote(marker.to_str().unwrap())
9372            ),
9373        )
9374        .unwrap();
9375        let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
9376        permissions.set_mode(0o755);
9377        std::fs::set_permissions(&hook, permissions).unwrap();
9378        test_git(&path, &["config", "commit.gpgsign", "true"]);
9379        test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
9380        std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
9381        test_git(&path, &["add", "managed.txt"]);
9382
9383        repo.commit_staged_changes(&path, "record managed change")
9384            .unwrap();
9385
9386        assert!(!marker.exists());
9387        assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
9388    }
9389
9390    #[test]
9391    fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
9392        let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
9393        std::fs::write(
9394            path.join(".gitattributes"),
9395            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
9396        )
9397        .unwrap();
9398        test_git(&path, &["add", ".gitattributes"]);
9399        test_git(&path, &["commit", "-m", "select automatic text"]);
9400        test_git(&path, &["config", "core.autocrlf", "true"]);
9401
9402        assert!(repository_has_recoverable_work(&path, true).unwrap());
9403    }
9404
9405    #[test]
9406    fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
9407        let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
9408        std::fs::write(
9409            path.join(".gitattributes"),
9410            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
9411        )
9412        .unwrap();
9413        test_git(&path, &["add", ".gitattributes"]);
9414        test_git(&path, &["commit", "-m", "select ident expansion"]);
9415        std::fs::write(path.join("README.md"), "seed\n").unwrap();
9416
9417        assert!(repository_has_recoverable_work(&path, true).unwrap());
9418    }
9419
9420    /// Add ignore rules the way SPAR does, without a tracked change the
9421    /// worktree would then be kept for.
9422    /// The failure this whole rule was rewritten for: a reviewer ran the
9423    /// project's binding generator to check a finding, and the lockfile it left
9424    /// beside its own manifest matched no directory name spar knew.
9425    #[test]
9426    fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
9427        let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
9428        exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
9429        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9430        std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
9431        std::fs::write(
9432            path.join("bindings/uniffi-bindgen/Cargo.lock"),
9433            "# generated\n",
9434        )
9435        .unwrap();
9436
9437        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9438            .expect("a path the project ignores is the call's own output");
9439    }
9440
9441    #[test]
9442    fn an_attribute_file_under_build_output_is_not_a_filter_change() {
9443        let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
9444        exclude_paths(&repo, &["target/"]);
9445        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9446        std::fs::create_dir_all(path.join("target/package")).unwrap();
9447        std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
9448
9449        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9450            .expect("an ignored attribute file governs only ignored paths");
9451        assert!(
9452            !repo.has_uncommitted_changes(&path).unwrap(),
9453            "ignored dependency attributes must not stop the next review"
9454        );
9455    }
9456
9457    #[test]
9458    fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
9459        let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
9460        std::fs::create_dir_all(path.join("source")).unwrap();
9461        std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
9462
9463        assert!(repo.has_uncommitted_changes(&path).unwrap());
9464    }
9465
9466    #[test]
9467    fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
9468        for source in ["repository", "nested", "local", "global"] {
9469            let (_fixture, repo, path, _initial) = review_fixture(source, 945);
9470            std::fs::create_dir_all(path.join("manager")).unwrap();
9471            match source {
9472                "repository" => {
9473                    std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
9474                        .unwrap();
9475                    test_git(&path, &["add", ".gitignore"]);
9476                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
9477                }
9478                "nested" => {
9479                    std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
9480                    test_git(&path, &["add", "manager/.gitignore"]);
9481                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
9482                }
9483                "local" => exclude_paths(&repo, &["manager/scratch/"]),
9484                "global" => {
9485                    let excludes = repo.root().parent().unwrap().join("global-ignore");
9486                    std::fs::write(&excludes, "manager/scratch/\n").unwrap();
9487                    test_git(
9488                        &path,
9489                        &["config", "core.excludesFile", excludes.to_str().unwrap()],
9490                    );
9491                }
9492                _ => unreachable!(),
9493            }
9494            let output = path.join("manager/scratch");
9495            std::fs::create_dir_all(&output).unwrap();
9496            std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
9497            std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
9498            let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9499            std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
9500            std::fs::remove_file(output.join(".DS_Store")).unwrap();
9501            for file in [
9502                ".gitattributes",
9503                "Cargo.lock",
9504                "font.woff2",
9505                "bundle.js",
9506                "cache.db",
9507            ] {
9508                std::fs::write(output.join(file), "generated\n").unwrap();
9509            }
9510            #[cfg(unix)]
9511            std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
9512            let nested = output.join("dependency");
9513            std::fs::create_dir_all(&nested).unwrap();
9514            test_git(&nested, &["init"]);
9515            repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9516                .unwrap_or_else(|error| panic!("{source}: {error}"));
9517            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9518
9519            let baseline = repo.worktree_baseline(&path).unwrap();
9520            std::fs::write(path.join("README.md"), "source fix\n").unwrap();
9521            std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
9522            std::fs::remove_file(output.join("Cargo.lock")).unwrap();
9523            std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
9524            std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
9525            assert!(repo
9526                .commit_pending_changes(&path, &baseline, "fix source", "fix source")
9527                .unwrap_or_else(|error| panic!("{source}: {error}")));
9528            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9529            assert!(test_git(
9530                &path,
9531                &[
9532                    "ls-tree",
9533                    "-r",
9534                    "--name-only",
9535                    "HEAD",
9536                    "--",
9537                    "manager/scratch"
9538                ]
9539            )
9540            .is_empty());
9541            assert!(
9542                repository_has_recoverable_work(&path, true).unwrap(),
9543                "{source}"
9544            );
9545            repo.release_review_worktree(945);
9546            assert_eq!(
9547                "LOCAL=updated\n",
9548                std::fs::read_to_string(output.join(".env.local")).unwrap()
9549            );
9550            assert!(nested.join(".git").exists(), "{source}");
9551        }
9552    }
9553
9554    /// Two reviewers share one checkout. The marker written when the first
9555    /// one's answer was refused used to be a brand new untracked file in the
9556    /// snapshot the second one was compared against.
9557    #[test]
9558    fn a_recovery_marker_is_not_a_change_to_the_worktree() {
9559        let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
9560        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9561        let _ = uncertain_worktree_change(&path, "an earlier call was refused");
9562
9563        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9564            .expect("spar's own marker is not something a call did");
9565    }
9566
9567    #[test]
9568    fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
9569        let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
9570
9571        let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
9572            .expect("a path a build removed is not an unreadable worktree");
9573
9574        assert_eq!(vanished_untracked_file(), gone);
9575    }
9576
9577    /// A path that goes is still a path that was there. Dropping it from the
9578    /// snapshot would let it pass for one the call never touched.
9579    #[test]
9580    fn a_vanished_path_is_not_recorded_as_absent() {
9581        let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
9582        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
9583        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9584        std::fs::remove_file(path.join("notes.txt")).unwrap();
9585
9586        let error = repo
9587            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9588            .unwrap_err();
9589
9590        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9591    }
9592
9593    fn exclude_paths(repo: &Repo, lines: &[&str]) {
9594        use std::io::Write;
9595        let path = repo.root().join(".git").join("info").join("exclude");
9596        let mut file = std::fs::OpenOptions::new()
9597            .create(true)
9598            .append(true)
9599            .open(&path)
9600            .unwrap();
9601        for line in lines {
9602            writeln!(file, "{line}").unwrap();
9603        }
9604    }
9605
9606    #[test]
9607    fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
9608        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
9609        exclude_paths(&repo, &["dist/"]);
9610        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9611        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9612        let baseline = repo.worktree_baseline(&path).unwrap();
9613        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9614        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9615        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9616        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9617
9618        assert!(repo
9619            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9620            .unwrap());
9621        assert_eq!(
9622            "tracked change\n",
9623            std::fs::read_to_string(path.join("README.md")).unwrap()
9624        );
9625    }
9626
9627    #[test]
9628    fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
9629        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
9630        exclude_paths(&repo, &["dist/"]);
9631        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9632        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9633        std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
9634        let baseline = repo.worktree_baseline(&path).unwrap();
9635        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9636        std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
9637        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9638        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9639
9640        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9641        repo.refuse_changed_existing_untracked(&path, &baseline)
9642            .unwrap();
9643    }
9644
9645    #[test]
9646    fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
9647        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
9648        exclude_paths(&repo, &["dist/"]);
9649        std::fs::create_dir_all(path.join("dist")).unwrap();
9650        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
9651        let baseline = repo.worktree_baseline(&path).unwrap();
9652        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
9653        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
9654
9655        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9656        repo.refuse_changed_existing_untracked(&path, &baseline)
9657            .unwrap();
9658    }
9659
9660    /// The run this comes from: a reviewer rebuilt a project whose build output
9661    /// lands in `manager/public/assets/`, which no directory list will ever
9662    /// name. The guard inside the call kept the review. This check then called
9663    /// the same thirteen files unrepresentable and ended the round, five
9664    /// minutes of review thrown away over a path Git had been told to disown.
9665    /// The run this comes from: `gh pr merge --delete-branch` could not delete
9666    /// `issue-784` because `.spar-worktrees/issue-784`, left by the run that
9667    /// opened the pull request, still had it checked out.
9668    #[test]
9669    fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
9670        let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
9671        let held = repo.root().join(WORKTREE_DIR).join("issue-950");
9672        std::fs::create_dir_all(held.parent().unwrap()).unwrap();
9673        test_git(
9674            repo.root(),
9675            &[
9676                "worktree",
9677                "add",
9678                "-b",
9679                "issue-950",
9680                held.to_str().unwrap(),
9681                "main",
9682            ],
9683        );
9684
9685        let found = repo
9686            .worktree_holding("issue-950")
9687            .expect("the branch is checked out somewhere");
9688
9689        assert!(found.ends_with("issue-950"), "{}", found.display());
9690        assert_eq!(None, repo.worktree_holding("issue-951"));
9691    }
9692
9693    #[test]
9694    fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
9695        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
9696        exclude_paths(&repo, &["manager/public/assets/"]);
9697        std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
9698        let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
9699        std::fs::write(&bundle, "first build\n").unwrap();
9700        let baseline = repo.worktree_baseline(&path).unwrap();
9701        std::fs::write(&bundle, "second build\n").unwrap();
9702        std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
9703
9704        repo.refuse_read_only_leavings(&path, &baseline).unwrap();
9705        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9706    }
9707
9708    #[test]
9709    fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
9710        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
9711        exclude_paths(&repo, &["dist/"]);
9712        let baseline = repo.worktree_baseline(&path).unwrap();
9713        std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
9714
9715        let error = repo
9716            .refuse_read_only_leavings(&path, &baseline)
9717            .unwrap_err();
9718
9719        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9720        assert!(error.to_string().contains("scratch.md"), "{error}");
9721    }
9722
9723    #[test]
9724    fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
9725        let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
9726        exclude_paths(&repo, &["dist/", ".env.local"]);
9727        std::fs::create_dir_all(path.join("dist")).unwrap();
9728        let baseline = repo.worktree_baseline(&path).unwrap();
9729        std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
9730        std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
9731
9732        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9733        assert!(repository_has_recoverable_work(&path, true).unwrap());
9734    }
9735
9736    #[test]
9737    fn a_read_only_inspection_may_rebuild_generated_output() {
9738        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
9739        exclude_paths(&repo, &["dist/"]);
9740        std::fs::create_dir_all(path.join("dist")).unwrap();
9741        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
9742        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9743        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
9744        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
9745
9746        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9747            .unwrap();
9748    }
9749
9750    #[test]
9751    fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
9752        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
9753        exclude_paths(&repo, &["dist/", ".env.local"]);
9754        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
9755        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9756        std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
9757
9758        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9759            .unwrap();
9760    }
9761
9762    #[test]
9763    fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
9764        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
9765        exclude_paths(&repo, &["dist/", ".env.local"]);
9766        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
9767        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9768        std::fs::remove_file(path.join(".env.local")).unwrap();
9769
9770        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9771            .unwrap();
9772    }
9773
9774    #[test]
9775    fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
9776        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
9777        exclude_paths(&repo, &["dist/"]);
9778        std::fs::write(path.join("notes.md"), "before\n").unwrap();
9779        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9780        std::fs::write(path.join("notes.md"), "after\n").unwrap();
9781
9782        let error = repo
9783            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9784            .unwrap_err();
9785
9786        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9787        assert!(error.to_string().contains("notes.md"), "{error}");
9788    }
9789
9790    #[test]
9791    fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
9792        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
9793        exclude_paths(&repo, &["dist/", "local.env"]);
9794        std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
9795        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9796        // Stop ignoring it, so the same bytes are now an ordinary untracked
9797        // file. What a path is counts, not only whether it moved.
9798        std::fs::write(
9799            repo.root().join(".git").join("info").join("exclude"),
9800            "dist/\n",
9801        )
9802        .unwrap();
9803
9804        let error = repo
9805            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9806            .unwrap_err();
9807
9808        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9809        assert!(error.to_string().contains("local.env"), "{error}");
9810    }
9811
9812    #[test]
9813    fn a_read_only_inspection_names_the_tracked_file_that_moved() {
9814        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
9815        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9816        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9817
9818        let error = repo
9819            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9820            .unwrap_err();
9821
9822        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9823        assert!(error.to_string().contains("README.md"), "{error}");
9824    }
9825
9826    #[test]
9827    fn build_output_alone_does_not_keep_a_worktree() {
9828        let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
9829        exclude_paths(&repo, &["target/", "dist/"]);
9830        std::fs::create_dir_all(path.join("target/debug")).unwrap();
9831        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
9832        std::fs::create_dir_all(path.join("dist/cli")).unwrap();
9833        std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
9834
9835        assert!(!repository_has_recoverable_work(&path, true).unwrap());
9836        repo.release_review_worktree(933);
9837
9838        assert!(!path.exists());
9839    }
9840
9841    #[test]
9842    fn finder_metadata_alone_does_not_keep_a_worktree() {
9843        let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
9844        exclude_paths(&repo, &[".DS_Store"]);
9845        std::fs::create_dir_all(path.join("src")).unwrap();
9846        std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
9847        std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
9848
9849        assert!(!repository_has_recoverable_work(&path, true).unwrap());
9850        repo.release_review_worktree(937);
9851
9852        assert!(!path.exists());
9853    }
9854
9855    #[test]
9856    fn a_fetch_record_alone_does_not_keep_a_worktree() {
9857        let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
9858        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9859        let git_dir = if raw.is_absolute() {
9860            raw
9861        } else {
9862            path.join(raw)
9863        };
9864        let head = test_git(&path, &["rev-parse", "HEAD"]);
9865        std::fs::write(
9866            git_dir.join("FETCH_HEAD"),
9867            format!("{}\t\tbranch 'main' of origin\n", head.trim()),
9868        )
9869        .unwrap();
9870
9871        assert!(!repository_has_recoverable_work(&path, true).unwrap());
9872        repo.release_review_worktree(938);
9873
9874        assert!(!path.exists());
9875    }
9876
9877    #[test]
9878    fn an_ignored_file_outside_build_output_keeps_a_worktree() {
9879        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
9880        exclude_paths(&repo, &["target/", ".env.local"]);
9881        std::fs::create_dir_all(path.join("target/debug")).unwrap();
9882        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
9883        std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
9884
9885        assert!(repository_has_recoverable_work(&path, true).unwrap());
9886        repo.release_review_worktree(934);
9887
9888        assert_eq!(
9889            "TOKEN=keep me\n",
9890            std::fs::read_to_string(path.join(".env.local")).unwrap()
9891        );
9892    }
9893
9894    #[test]
9895    fn a_repository_nested_in_build_output_keeps_a_worktree() {
9896        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
9897        exclude_paths(&repo, &["node_modules/"]);
9898        let nested = path.join("node_modules/local-dep");
9899        std::fs::create_dir_all(&nested).unwrap();
9900        test_git(&nested, &["init"]);
9901        std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
9902
9903        assert!(repository_has_recoverable_work(&path, true).unwrap());
9904        repo.release_review_worktree(935);
9905
9906        assert!(nested.join(".git").exists());
9907    }
9908
9909    #[test]
9910    fn an_ordinary_untracked_file_keeps_a_worktree() {
9911        let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
9912        std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
9913
9914        assert!(repository_has_recoverable_work(&path, true).unwrap());
9915        repo.release_review_worktree(936);
9916
9917        assert_eq!(
9918            "somebody's notes\n",
9919            std::fs::read_to_string(path.join("notes.md")).unwrap()
9920        );
9921    }
9922
9923    #[test]
9924    fn a_legacy_crlf_checkout_is_retained_conservatively() {
9925        let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
9926        std::fs::write(
9927            path.join(".gitattributes"),
9928            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
9929        )
9930        .unwrap();
9931        test_git(&path, &["add", ".gitattributes"]);
9932        test_git(&path, &["commit", "-m", "select legacy line endings"]);
9933
9934        assert!(repository_has_recoverable_work(&path, true).unwrap());
9935    }
9936
9937    #[test]
9938    fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
9939        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
9940        let nested = path.join("tracked");
9941        std::fs::create_dir_all(&nested).unwrap();
9942        std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
9943        test_git(&path, &["add", "tracked/seed.txt"]);
9944        test_git(&path, &["commit", "-m", "add tracked directory"]);
9945        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9946        test_git(&nested, &["init"]);
9947
9948        let error = repo
9949            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9950            .unwrap_err();
9951
9952        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9953        assert!(error.to_string().contains("Git entry"), "{error}");
9954        assert!(nested.join(".git").exists());
9955    }
9956
9957    /// A reviewer that clones a reproduction into ignored build output used to
9958    /// lose the whole review. The state check refused every Git entry it had
9959    /// not put there, including the one Git reports as a nested checkout and
9960    /// the untracked snapshot already records by identity.
9961    #[test]
9962    fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
9963        let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
9964        exclude_paths(&repo, &["target/"]);
9965        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9966        let repro = path.join("target/pr918-repro");
9967        std::fs::create_dir_all(&repro).unwrap();
9968        test_git(&repro, &["init"]);
9969        std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
9970
9971        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9972            .unwrap();
9973
9974        assert!(repository_has_recoverable_work(&path, true).unwrap());
9975        assert!(repro.join(".git").exists());
9976    }
9977
9978    /// What sits under a nested checkout is that repository's business, so the
9979    /// scan stops at its directory rather than reading a checkout of its own
9980    /// as an entry the outer worktree has to account for.
9981    #[test]
9982    fn a_repository_inside_a_nested_checkout_is_left_alone() {
9983        let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
9984        exclude_paths(&repo, &["target/"]);
9985        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9986        let repro = path.join("target/repro");
9987        let vendored = repro.join("vendor/dep");
9988        std::fs::create_dir_all(&vendored).unwrap();
9989        test_git(&repro, &["init"]);
9990        test_git(&vendored, &["init"]);
9991
9992        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9993            .unwrap();
9994
9995        assert!(vendored.join(".git").exists());
9996    }
9997
9998    #[test]
9999    fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
10000        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
10001
10002        let state = ignored_untracked_state(repo.root()).unwrap();
10003
10004        let relative = path.strip_prefix(repo.root()).unwrap();
10005        assert!(
10006            state.files.contains_key(relative),
10007            "{:?}",
10008            state.files.keys().collect::<Vec<_>>()
10009        );
10010        assert!(state.is_ignored(relative));
10011    }
10012
10013    #[test]
10014    fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
10015        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
10016        let baseline = repo.worktree_baseline(repo.root()).unwrap();
10017        std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
10018        std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
10019
10020        repo.refuse_new_ignored_files(repo.root(), &baseline)
10021            .unwrap();
10022        repo.refuse_changed_existing_untracked(repo.root(), &baseline)
10023            .unwrap();
10024    }
10025
10026    #[test]
10027    fn deleting_a_resident_worktree_during_a_call_is_refused() {
10028        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
10029        let baseline = repo.worktree_baseline(repo.root()).unwrap();
10030        let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
10031        std::fs::remove_dir_all(&path).unwrap();
10032
10033        let read_error = repo
10034            .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
10035            .unwrap_err();
10036        assert!(
10037            read_error.to_string().contains("review-932"),
10038            "{read_error}"
10039        );
10040        let error = repo
10041            .refuse_new_ignored_files(repo.root(), &baseline)
10042            .unwrap_err();
10043
10044        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10045        assert!(error.to_string().contains("review-932"), "{error}");
10046    }
10047
10048    #[test]
10049    fn a_nested_repository_record_is_read_as_a_plain_path() {
10050        let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
10051        assert_eq!(Path::new("vendor/checkout"), path);
10052        assert!(nested);
10053
10054        let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
10055        assert_eq!(Path::new("vendor/notes.txt"), path);
10056        assert!(!nested);
10057
10058        assert!(untracked_record(b"/", "untracked").is_err());
10059    }
10060
10061    #[cfg(unix)]
10062    #[test]
10063    fn a_non_utf8_git_path_is_preserved_without_loss() {
10064        use std::os::unix::ffi::OsStrExt;
10065
10066        let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
10067
10068        assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
10069    }
10070
10071    #[test]
10072    fn guarded_merge_pins_the_reviewed_head() {
10073        let args = merge_pr_args("36", Some("abc123"), true);
10074        assert_eq!(
10075            vec![
10076                "pr",
10077                "merge",
10078                "36",
10079                "--squash",
10080                "--delete-branch",
10081                "--match-head-commit",
10082                "abc123"
10083            ],
10084            args
10085        );
10086    }
10087
10088    #[test]
10089    fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
10090        let pr = PrRef {
10091            number: 7,
10092            url: "https://example.test/pull/7".into(),
10093            title: "part one".into(),
10094        };
10095        let result = reconcile_pr_creation(
10096            "split-34-1",
10097            Err(crate::error::SparError::new("connection lost")),
10098            Ok(Some(pr)),
10099        )
10100        .unwrap();
10101        assert_eq!(7, result.number);
10102    }
10103
10104    #[test]
10105    fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
10106        let error = reconcile_pr_creation(
10107            "split-34-1",
10108            Err(crate::error::SparError::new("permission denied")),
10109            Ok(None),
10110        )
10111        .unwrap_err();
10112        assert!(error.to_string().contains("permission denied"), "{error}");
10113    }
10114
10115    #[test]
10116    fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
10117        let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
10118        assert!(pr_for_base(text, "split-34-2", "split-34-1")
10119            .unwrap()
10120            .is_none());
10121        let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
10122        assert_eq!(7, found.number);
10123    }
10124
10125    #[test]
10126    fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
10127        let result = reconcile_comment_post(
10128            34,
10129            "the summary",
10130            crate::error::SparError::new("connection lost"),
10131            Ok(vec![serde_json::json!({"body": "the summary"})]),
10132        );
10133        assert!(result.is_ok(), "{result:?}");
10134    }
10135
10136    #[test]
10137    fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
10138        let error = reconcile_comment_post(
10139            34,
10140            "the summary",
10141            crate::error::SparError::new("connection lost"),
10142            Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
10143        )
10144        .unwrap_err();
10145        assert_eq!("connection lost", error.to_string());
10146    }
10147
10148    #[test]
10149    fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
10150        let error = reconcile_comment_post(
10151            34,
10152            "the summary",
10153            crate::error::SparError::new("connection lost"),
10154            Err(crate::error::SparError::new("comments unavailable")),
10155        )
10156        .unwrap_err();
10157        assert!(
10158            error.to_string().contains("could not be verified"),
10159            "{error}"
10160        );
10161        assert!(
10162            error.to_string().contains("comments unavailable"),
10163            "{error}"
10164        );
10165        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10166        assert!(!error.worth_retrying());
10167    }
10168
10169    #[test]
10170    fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
10171        let result = reconcile_issue_edit(
10172            34,
10173            "wanted body",
10174            crate::error::SparError::new("connection lost"),
10175            Ok("wanted body".to_string()),
10176        );
10177        assert!(result.is_ok(), "{result:?}");
10178    }
10179
10180    #[test]
10181    fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
10182        let error = reconcile_issue_edit(
10183            34,
10184            "wanted body",
10185            crate::error::SparError::new("connection lost"),
10186            Err(crate::error::SparError::new("issue unavailable")),
10187        )
10188        .unwrap_err();
10189        assert!(
10190            error.to_string().contains("could not be verified"),
10191            "{error}"
10192        );
10193        assert!(error.to_string().contains("issue unavailable"), "{error}");
10194        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10195        assert!(!error.worth_retrying());
10196    }
10197
10198    #[test]
10199    fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
10200        let found = ExistingIssue {
10201            number: 101,
10202            url: "https://example.test/issues/101".into(),
10203            title: "child".into(),
10204            body: "body".into(),
10205            open: true,
10206        };
10207        let url = reconcile_issue_creation(
10208            "child",
10209            Err(crate::error::SparError::new("connection lost")),
10210            Ok(Some(found)),
10211        )
10212        .unwrap();
10213        assert_eq!("https://example.test/issues/101", url);
10214    }
10215
10216    #[test]
10217    fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
10218        let error = reconcile_issue_creation(
10219            "child",
10220            Err(crate::error::SparError::new("permission denied")),
10221            Ok(None),
10222        )
10223        .unwrap_err();
10224        assert!(error.to_string().contains("permission denied"), "{error}");
10225    }
10226
10227    #[test]
10228    fn an_unverifiable_issue_creation_is_marked_uncertain() {
10229        let error = reconcile_issue_creation(
10230            "child",
10231            Err(crate::error::SparError::new("connection lost")),
10232            Err(crate::error::SparError::new("issues unavailable")),
10233        )
10234        .unwrap_err();
10235        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10236        assert!(!error.worth_retrying());
10237    }
10238
10239    #[test]
10240    fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
10241        let result = reconcile_failed_split_push(
10242            "split-34-1",
10243            crate::error::SparError::new("connection lost"),
10244            Ok("abc123\n".into()),
10245            Ok("abc123\trefs/heads/split-34-1\n".into()),
10246        );
10247        assert!(result.is_ok(), "{result:?}");
10248    }
10249
10250    #[test]
10251    fn a_split_push_collision_is_definite_and_never_overwrites() {
10252        let error = reconcile_failed_split_push(
10253            "split-34-1",
10254            crate::error::SparError::new("lease rejected"),
10255            Ok("abc123\n".into()),
10256            Ok("def456\trefs/heads/split-34-1\n".into()),
10257        )
10258        .unwrap_err();
10259        assert!(!error.retain_worktree());
10260        assert!(
10261            error.to_string().contains("Nothing was overwritten"),
10262            "{error}"
10263        );
10264    }
10265
10266    #[test]
10267    fn an_unreadable_split_push_result_keeps_the_worktree() {
10268        let error = reconcile_failed_split_push(
10269            "split-34-1",
10270            crate::error::SparError::new("connection lost"),
10271            Ok("abc123\n".into()),
10272            Err(crate::error::SparError::new("origin unavailable")),
10273        )
10274        .unwrap_err();
10275        assert!(error.retain_worktree());
10276        assert!(error.to_string().contains("could not confirm"), "{error}");
10277    }
10278
10279    /// Follow-up deduplication compares a title it computed against the title
10280    /// GitHub stored. If those two transforms can disagree, the check never
10281    /// matches and every review round files another copy of the same issue.
10282    #[test]
10283    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
10284        let repo = repo_for_titles();
10285        for raw in [
10286            "Retry loop spins \u{2014} Retry-After parses to zero",
10287            "plain title",
10288            "  spread   over\nlines  ",
10289            "\u{1F916} Generated with something",
10290            &format!("a \u{2014} {}", "very long title ".repeat(20)),
10291            &"x".repeat(300),
10292            &format!("{} \u{2014} end", "y".repeat(88)),
10293            // Exactly the budget, with two spaceless dashes. The scrub turns
10294            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
10295            // character over budget per dash and a second pass clips again,
10296            // producing a different string. Scrub-then-clip cannot.
10297            &{
10298                let tail = "a\u{2014}b c\u{2014}d";
10299                let pad = Style::default().max_title_chars - tail.chars().count();
10300                format!("{}{tail}", "w".repeat(pad))
10301            },
10302        ] {
10303            let once = repo.clean_title(raw).unwrap();
10304            let twice = repo.clean_title(&once).unwrap();
10305            assert_eq!(once, twice, "not idempotent for {raw:?}");
10306            assert!(
10307                once.chars().count() <= repo.style.max_title_chars,
10308                "over budget: {once:?}"
10309            );
10310            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
10311        }
10312    }
10313
10314    #[test]
10315    fn a_title_with_an_em_dash_survives_as_readable_text() {
10316        let repo = repo_for_titles();
10317        assert_eq!(
10318            "Retry loop spins, Retry-After parses to zero",
10319            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
10320                .unwrap()
10321        );
10322    }
10323
10324    #[test]
10325    fn sh_quote_survives_a_quote() {
10326        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
10327    }
10328
10329    #[test]
10330    fn sh_quote_wraps_a_space() {
10331        assert_eq!(
10332            "'/Applications/My App/spar'",
10333            sh_quote("/Applications/My App/spar")
10334        );
10335    }
10336
10337    #[test]
10338    fn finished_states_are_recognised_case_insensitively() {
10339        assert!(is_finished("MERGED"));
10340        assert!(is_finished("closed"));
10341        assert!(!is_finished("OPEN"));
10342        assert!(!is_finished(""));
10343    }
10344
10345    fn state() -> PersistedState {
10346        PersistedState {
10347            version: 1,
10348            checkpoint: 0,
10349            round: 4,
10350            next_actor: "codex".into(),
10351            status: Status::Pending,
10352            pr_head: "abc123".into(),
10353            ledger: Ledger::new(),
10354            filed: vec![],
10355            open_findings: vec![Finding {
10356                severity: Severity::Blocking,
10357                title: "Unchecked error".into(),
10358                detail: "the failure is discarded".into(),
10359                file: "src/a.rs:12".into(),
10360                ..Finding::default()
10361            }],
10362            disputes: vec![Dispute {
10363                title: "Retry limit".into(),
10364                file: "src/net.rs".into(),
10365                reasoning: "the caller already bounds it".into(),
10366            }],
10367            noted: vec![Finding {
10368                severity: Severity::NonBlocking,
10369                title: "Timeout is fixed".into(),
10370                file: "src/config.rs".into(),
10371                ..Finding::default()
10372            }],
10373        }
10374    }
10375
10376    #[test]
10377    fn a_state_comment_round_trips() {
10378        let body = format!(
10379            "{STATE_MARKER}\n{}\n-->",
10380            serde_json::to_string(&state()).unwrap()
10381        );
10382        let back = parse_state_comment(&body).unwrap();
10383        assert_eq!(4, back.round);
10384        assert_eq!("codex", back.next_actor);
10385        assert_eq!("abc123", back.pr_head);
10386        assert_eq!("Unchecked error", back.open_findings[0].title);
10387        assert_eq!("src/net.rs", back.disputes[0].file);
10388        assert_eq!("Timeout is fixed", back.noted[0].title);
10389    }
10390
10391    #[test]
10392    fn old_state_without_new_lists_still_parses() {
10393        let body = format!(
10394            "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
10395             \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
10396        );
10397        let back = parse_state_comment(&body).expect("old state");
10398        assert!(back.open_findings.is_empty());
10399        assert!(back.disputes.is_empty());
10400        assert!(back.noted.is_empty());
10401        assert!(back.pr_head.is_empty());
10402        assert_eq!(0, back.checkpoint);
10403    }
10404
10405    #[test]
10406    fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
10407        let mut local = state();
10408        local.pr_head = "old".into();
10409        local.round = 9;
10410        let mut remote = state();
10411        remote.pr_head = "current".into();
10412        remote.round = 4;
10413
10414        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10415        assert_eq!("current", chosen.pr_head);
10416        assert_eq!(4, chosen.round);
10417    }
10418
10419    #[test]
10420    fn checkpoint_order_breaks_same_round_ties() {
10421        let mut local = state();
10422        local.pr_head = "current".into();
10423        local.round = 4;
10424        local.checkpoint = 8;
10425        let mut remote = local.clone();
10426        remote.checkpoint = 7;
10427        remote.open_findings.clear();
10428
10429        let chosen = choose_state_for_head(vec![local], "current").unwrap();
10430        assert_eq!(8, chosen.checkpoint);
10431
10432        let mut local = state();
10433        local.pr_head = "current".into();
10434        local.round = 4;
10435        local.checkpoint = 8;
10436        let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
10437        assert_eq!(8, chosen.checkpoint);
10438    }
10439
10440    #[test]
10441    fn legacy_same_round_tie_keeps_the_local_checkpoint() {
10442        let mut local = state();
10443        local.pr_head = "current".into();
10444        local.round = 4;
10445        local.open_findings.push(Finding {
10446            title: "local checkpoint".into(),
10447            ..Finding::default()
10448        });
10449        let mut remote = state();
10450        remote.pr_head = "current".into();
10451        remote.round = 4;
10452
10453        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10454        assert_eq!(
10455            "local checkpoint",
10456            chosen.open_findings.last().unwrap().title
10457        );
10458    }
10459
10460    /// It must render as nothing, so PRs are not littered with machine state.
10461    #[test]
10462    fn the_state_block_is_an_html_comment() {
10463        let body = format!(
10464            "{STATE_MARKER}\n{}\n-->",
10465            serde_json::to_string(&state()).unwrap()
10466        );
10467        assert!(body.starts_with("<!--"));
10468        assert!(body.trim_end().ends_with("-->"));
10469        assert!(!body[..body.find('{').unwrap()].contains("-->"));
10470    }
10471
10472    #[test]
10473    fn an_unrelated_json_block_is_not_state() {
10474        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
10475    }
10476
10477    /// Patching and deleting a comment is not something to do on a substring.
10478    ///
10479    /// A maintainer who notices the block and asks about it writes a comment
10480    /// containing `spar:state`, and the loose filter could not tell theirs from
10481    /// spar's. The next `persist` replaced their words with JSON and
10482    /// `spar clean --pr-state` later deleted it.
10483    #[test]
10484    fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
10485        let block = format!(
10486            "{STATE_MARKER}\n{}\n-->",
10487            serde_json::to_string(&state()).unwrap()
10488        );
10489        assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
10490        assert!(
10491            is_state_comment(&block, "Spar-Bot", "spar-bot"),
10492            "a login differs only in case"
10493        );
10494
10495        assert!(
10496            !is_state_comment(
10497                "why is there a `<!-- spar:state` block on this PR?",
10498                "maintainer",
10499                "spar-bot"
10500            ),
10501            "a question about the block was treated as the block"
10502        );
10503        assert!(
10504            !is_state_comment(&block, "maintainer", "spar-bot"),
10505            "somebody else's comment is not spar's to rewrite"
10506        );
10507        assert!(
10508            !is_state_comment(
10509                &format!("here is what spar writes:\n\n{block}"),
10510                "spar-bot",
10511                "spar-bot"
10512            ),
10513            "the marker has to open the body, not appear in it"
10514        );
10515        assert!(
10516            !is_state_comment(
10517                &format!("{STATE_MARKER}\n{{not json\n-->"),
10518                "spar-bot",
10519                "spar-bot"
10520            ),
10521            "a block that does not parse is not state"
10522        );
10523        assert!(
10524            !is_state_comment(&block, "spar-bot", ""),
10525            "an unknown viewer proves nothing about who wrote it"
10526        );
10527    }
10528
10529    #[test]
10530    fn a_malformed_state_comment_is_none_not_a_panic() {
10531        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
10532    }
10533
10534    #[test]
10535    fn atomic_write_leaves_no_temp_file() {
10536        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
10537        let _ = std::fs::remove_dir_all(&dir);
10538        let path = dir.join("state").join("pr-7.json");
10539        write_json_atomic(&path, &state()).unwrap();
10540        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
10541            .unwrap()
10542            .flatten()
10543            .filter_map(|e| e.file_name().to_str().map(str::to_string))
10544            .collect();
10545        assert_eq!(vec!["pr-7.json".to_string()], files);
10546        let _ = std::fs::remove_dir_all(&dir);
10547    }
10548
10549    #[test]
10550    fn atomic_write_overwrites_rather_than_accumulating() {
10551        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
10552        let _ = std::fs::remove_dir_all(&dir);
10553        let path = dir.join("pr-7.json");
10554        for round in 1..4 {
10555            let mut s = state();
10556            s.round = round;
10557            write_json_atomic(&path, &s).unwrap();
10558        }
10559        let back: PersistedState =
10560            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10561        assert_eq!(3, back.round);
10562        let _ = std::fs::remove_dir_all(&dir);
10563    }
10564
10565    #[test]
10566    fn style_from_env_defaults_to_enforcing() {
10567        std::env::remove_var("SPAR_BAN_EM_DASH");
10568        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
10569        let style = style_from_env();
10570        assert!(style.ban_em_dash && style.ban_ai_attribution);
10571        assert!(
10572            !style.terse,
10573            "the commit filter must not truncate a commit message"
10574        );
10575    }
10576
10577    // -- publishing from a fork --------------------------------------------
10578
10579    /// GitHub's refusal is several lines of advice after the one that says
10580    /// what happened, and spar used to report only the last of them.
10581    #[test]
10582    fn a_push_denial_is_read_from_either_transport() {
10583        let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
10584                   fatal: Could not read from remote repository.\n\n\
10585                   Please make sure you have the correct access rights\n\
10586                   and the repository exists.";
10587        let https = "remote: Permission to example/project.git denied to someone.\n\
10588                     fatal: unable to access 'https://github.com/example/project.git/': The \
10589                     requested URL returned error: 403";
10590        let denied = Some("Permission to example/project.git denied to someone.");
10591        assert_eq!(denied, push_denial(ssh));
10592        assert_eq!(denied, push_denial(https));
10593
10594        let stale = "To github.com:example/project.git\n \
10595                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
10596                     error: failed to push some refs to 'github.com:example/project.git'";
10597        assert_eq!(None, push_denial(stale));
10598
10599        let err = push_error(
10600            &PushTarget::Origin,
10601            "issue-7",
10602            &crate::error::SparError::new(ssh),
10603        );
10604        assert_eq!(
10605            "could not push to origin/issue-7. Permission to example/project.git denied to \
10606             someone.",
10607            err.to_string()
10608        );
10609    }
10610
10611    /// An origin whose hook refuses every push with GitHub's wording, a bare
10612    /// repository standing in for the fork, and a checkout on a new branch
10613    /// with one commit to publish.
10614    #[cfg(unix)]
10615    fn denied_fixture(
10616        tag: &str,
10617        fork_when_denied: bool,
10618    ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
10619        use std::os::unix::fs::PermissionsExt;
10620        use std::sync::atomic::{AtomicU32, Ordering};
10621        static NEXT: AtomicU32 = AtomicU32::new(0);
10622        let id = NEXT.fetch_add(1, Ordering::Relaxed);
10623        let root =
10624            std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
10625        let origin = root.join("origin.git");
10626        let fork = root.join("fork.git");
10627        let work = root.join("work");
10628        for dir in [&origin, &fork, &work] {
10629            std::fs::create_dir_all(dir).unwrap();
10630        }
10631        test_git(&origin, &["init", "--bare", "-b", "main"]);
10632        test_git(&fork, &["init", "--bare", "-b", "main"]);
10633        test_git(&work, &["init", "-b", "main"]);
10634        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
10635        test_git(&work, &["config", "user.name", "spar test"]);
10636        test_git(&work, &["config", "commit.gpgsign", "false"]);
10637        std::fs::write(work.join("README.md"), "seed\n").unwrap();
10638        test_git(&work, &["add", "."]);
10639        test_git(&work, &["commit", "-m", "seed"]);
10640        test_git(
10641            &work,
10642            &["remote", "add", "origin", origin.to_str().unwrap()],
10643        );
10644        test_git(&work, &["push", "-q", "origin", "main"]);
10645        // Installed after the seed, so it is only spar's pushes it refuses.
10646        let hook = origin.join("hooks").join("pre-receive");
10647        std::fs::write(
10648            &hook,
10649            "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
10650        )
10651        .unwrap();
10652        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
10653        test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
10654        std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
10655        test_git(&work, &["add", "."]);
10656        test_git(&work, &["commit", "-m", "Fix it"]);
10657
10658        let mut text =
10659            String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
10660        if !fork_when_denied {
10661            text.push_str("[loop]\nfork_when_denied = false\n");
10662        }
10663        let cfg = crate::config::parse(&text).unwrap();
10664        let repo = Repo::open(&work, &cfg).unwrap();
10665        // What `fork` would have asked GitHub for, so no test reaches it.
10666        repo.fork
10667            .set(Fork {
10668                owner: "someone".into(),
10669                full_name: "someone/project".into(),
10670                url: fork.to_str().unwrap().into(),
10671            })
10672            .unwrap();
10673        (ReviewFixture { root }, repo, work, origin, fork)
10674    }
10675
10676    #[cfg(unix)]
10677    #[test]
10678    fn a_new_branch_origin_refuses_is_published_from_the_fork() {
10679        let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
10680        let first = test_git(&work, &["rev-parse", "HEAD"]);
10681
10682        let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
10683
10684        assert_eq!("someone:issue-7", target.head("issue-7"));
10685        assert_eq!(
10686            first.trim(),
10687            test_git(&fork, &["rev-parse", "issue-7"]).trim()
10688        );
10689        assert!(test_git(&origin, &["branch", "--list", "issue-7"])
10690            .trim()
10691            .is_empty());
10692        assert_eq!(
10693            WriteSummary {
10694                attempted: 1,
10695                failed: 0
10696            },
10697            repo.write_summary(),
10698            "a refusal the fork answered is not a failed write"
10699        );
10700
10701        // A later round follows the branch to the fork, leasing what it
10702        // published there.
10703        std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
10704        test_git(&work, &["commit", "-qam", "Fix it again"]);
10705        repo.push_to(&work, &target, "issue-7", Some(first.trim()))
10706            .unwrap();
10707        let second = test_git(&work, &["rev-parse", "HEAD"]);
10708        assert_eq!(
10709            second.trim(),
10710            test_git(&fork, &["rev-parse", "issue-7"]).trim()
10711        );
10712    }
10713
10714    /// What a closed pull request leaves on the fork: a branch with nothing
10715    /// a pull request does not hold. Origin's issue branches are rebuilt over
10716    /// in the same case.
10717    #[cfg(unix)]
10718    #[test]
10719    fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
10720        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
10721        test_git(
10722            &work,
10723            &[
10724                "push",
10725                "-q",
10726                fork.to_str().unwrap(),
10727                "main:refs/heads/issue-7",
10728            ],
10729        );
10730        let head = test_git(&work, &["rev-parse", "HEAD"]);
10731
10732        repo.publish_new_branch(&work, "issue-7", "main").unwrap();
10733
10734        assert_eq!(
10735            head.trim(),
10736            test_git(&fork, &["rev-parse", "issue-7"]).trim()
10737        );
10738        assert_eq!(0, repo.write_summary().failed);
10739    }
10740
10741    #[cfg(unix)]
10742    #[test]
10743    fn work_only_the_fork_has_is_not_published_over() {
10744        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
10745        test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
10746        std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
10747        test_git(&work, &["add", "."]);
10748        test_git(&work, &["commit", "-qm", "Somebody's work"]);
10749        test_git(
10750            &work,
10751            &[
10752                "push",
10753                "-q",
10754                fork.to_str().unwrap(),
10755                "elsewhere:refs/heads/issue-7",
10756            ],
10757        );
10758        let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
10759        test_git(&work, &["checkout", "-q", "issue-7"]);
10760
10761        let err = repo
10762            .publish_new_branch(&work, "issue-7", "main")
10763            .unwrap_err()
10764            .to_string();
10765
10766        assert!(
10767            err.contains(
10768                "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
10769            ),
10770            "{err}"
10771        );
10772        assert!(err.contains("--delete issue-7"), "{err}");
10773        assert_eq!(
10774            theirs.trim(),
10775            test_git(&fork, &["rev-parse", "issue-7"]).trim()
10776        );
10777        assert_eq!(
10778            WriteSummary {
10779                attempted: 1,
10780                failed: 1
10781            },
10782            repo.write_summary()
10783        );
10784    }
10785
10786    /// A lease that loses says which ref and why, not git's closing line.
10787    #[test]
10788    fn a_rejected_push_names_the_ref_it_rejected() {
10789        let stale = "To github.com:example/project.git\n \
10790                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
10791                     error: failed to push some refs to 'github.com:example/project.git'";
10792
10793        let err = push_error(
10794            &PushTarget::Origin,
10795            "issue-7",
10796            &crate::error::SparError::new(stale),
10797        )
10798        .to_string();
10799
10800        assert!(
10801            err.starts_with(
10802                "could not push to origin/issue-7. [rejected]        HEAD -> issue-7 (stale info)"
10803            ),
10804            "{err}"
10805        );
10806    }
10807
10808    #[test]
10809    fn a_fork_is_reached_by_whatever_reaches_origin() {
10810        let cases = [
10811            (
10812                "git@github.com:example/project.git",
10813                Some("git@github.com:someone/project.git"),
10814            ),
10815            // No user: ~/.ssh/config supplies it.
10816            (
10817                "github.com:example/project.git",
10818                Some("github.com:someone/project.git"),
10819            ),
10820            // A host alias, how one machine pushes as two accounts.
10821            (
10822                "git@github-work:example/project.git",
10823                Some("git@github-work:someone/project.git"),
10824            ),
10825            // SSH over 443, where 22 is blocked.
10826            (
10827                "ssh://git@ssh.github.com:443/example/project.git",
10828                Some("ssh://git@ssh.github.com:443/someone/project.git"),
10829            ),
10830            (
10831                "https://github.com/example/project",
10832                Some("https://github.com/someone/project"),
10833            ),
10834            (
10835                "https://ghe.example.com/scm/example/project.git/",
10836                Some("https://ghe.example.com/scm/someone/project.git"),
10837            ),
10838            (
10839                "git@host:/srv/example/project.git",
10840                Some("git@host:/srv/someone/project.git"),
10841            ),
10842            ("/srv/git/project.git", None),
10843            ("https://github.com/project", None),
10844            ("", None),
10845        ];
10846        for (origin, want) in cases {
10847            assert_eq!(
10848                want.map(str::to_string),
10849                fork_url(origin, "someone/project"),
10850                "{origin}"
10851            );
10852        }
10853    }
10854
10855    #[test]
10856    fn a_fork_found_or_made_is_reached_the_way_origin_is() {
10857        let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
10858            "ssh_url":"git@github.com:someone/project.git",
10859            "clone_url":"https://github.com/someone/project.git","fork":true}"#;
10860        let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
10861            "sshUrl":"git@github.com:someone/project.git",
10862            "url":"https://github.com/someone/project"}]"#;
10863
10864        let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
10865        assert_eq!("git@github-work:someone/project.git", fork.url);
10866        assert_eq!("someone", fork.owner);
10867        assert_eq!("someone/project", fork.full_name);
10868        let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
10869            .unwrap()
10870            .unwrap();
10871        assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
10872        assert_eq!(
10873            None,
10874            fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
10875        );
10876
10877        // An origin with no owner/repo path falls back to GitHub's URL, the
10878        // SSH one when origin looks like SSH.
10879        let fork = fork_from_api(made, "https://github.com/project").unwrap();
10880        assert_eq!("https://github.com/someone/project.git", fork.url);
10881        let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
10882        assert_eq!("git@github.com:someone/project.git", fork.url);
10883
10884        assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
10885        assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
10886    }
10887
10888    #[test]
10889    fn only_the_viewers_own_fork_can_be_pushed_to() {
10890        let pr = |owner: &str, name: &str| PrView {
10891            number: 9,
10892            url: String::new(),
10893            title: String::new(),
10894            body: String::new(),
10895            head_ref_name: "issue-7".into(),
10896            base_ref_name: "main".into(),
10897            state: "OPEN".into(),
10898            closing_issues_references: Vec::new(),
10899            is_cross_repository: true,
10900            head_repository_owner: Some(crate::model::Author {
10901                login: owner.into(),
10902            }),
10903            head_repository: Some(crate::model::HeadRepository { name: name.into() }),
10904        };
10905        let origin = "git@github.com:example/project.git";
10906
10907        assert_eq!(
10908            Some(PushTarget::Fork(Fork {
10909                owner: "Someone".into(),
10910                full_name: "Someone/project".into(),
10911                url: "git@github.com:Someone/project.git".into(),
10912            })),
10913            own_fork_target(&pr("Someone", "project"), "someone", origin)
10914        );
10915        assert_eq!(
10916            None,
10917            own_fork_target(&pr("other", "project"), "someone", origin)
10918        );
10919        assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
10920        assert_eq!(
10921            None,
10922            own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
10923        );
10924        let mut unnamed = pr("someone", "project");
10925        unnamed.head_repository_owner = None;
10926        assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
10927    }
10928
10929    /// `spar resume` and `spar checkin` on a pull request from the user's own
10930    /// fork check its branch out from the fork and push back there.
10931    #[cfg(unix)]
10932    #[test]
10933    fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
10934        let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
10935        test_git(
10936            &work,
10937            &[
10938                "push",
10939                "-q",
10940                fork.to_str().unwrap(),
10941                "issue-7:refs/heads/issue-7",
10942            ],
10943        );
10944        let published = test_git(&work, &["rev-parse", "issue-7"]);
10945        test_git(&work, &["checkout", "-q", "main"]);
10946        let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
10947        let pr = PrView {
10948            number: 31,
10949            url: String::new(),
10950            title: String::new(),
10951            body: String::new(),
10952            head_ref_name: "issue-7".into(),
10953            base_ref_name: "main".into(),
10954            state: "OPEN".into(),
10955            closing_issues_references: Vec::new(),
10956            is_cross_repository: true,
10957            head_repository_owner: None,
10958            head_repository: None,
10959        };
10960
10961        let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
10962
10963        assert_eq!("issue-7", branch);
10964        assert_eq!(
10965            published.trim(),
10966            test_git(&path, &["rev-parse", "HEAD"]).trim()
10967        );
10968        std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
10969        test_git(&path, &["commit", "-qam", "Answer a comment"]);
10970        repo.push_to(&path, &target, &branch, Some(published.trim()))
10971            .unwrap();
10972        assert_eq!(
10973            test_git(&path, &["rev-parse", "HEAD"]).trim(),
10974            test_git(&fork, &["rev-parse", "issue-7"]).trim()
10975        );
10976        assert!(test_git(&origin, &["branch", "--list", "issue-7"])
10977            .trim()
10978            .is_empty());
10979    }
10980
10981    #[cfg(unix)]
10982    #[test]
10983    fn with_forking_off_a_refused_push_says_why_and_how() {
10984        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
10985
10986        let err = repo
10987            .publish_new_branch(&work, "issue-7", "main")
10988            .unwrap_err()
10989            .to_string();
10990
10991        assert!(
10992            err.contains("Permission to example/project.git denied to someone."),
10993            "{err}"
10994        );
10995        assert!(err.contains("fork_when_denied = true"), "{err}");
10996        assert!(test_git(&fork, &["branch", "--list", "issue-7"])
10997            .trim()
10998            .is_empty());
10999        assert_eq!(
11000            WriteSummary {
11001                attempted: 1,
11002                failed: 1
11003            },
11004            repo.write_summary()
11005        );
11006    }
11007}
11008
11009#[cfg(test)]
11010mod comment_page_tests {
11011    use super::*;
11012
11013    #[test]
11014    fn a_single_merged_array_is_read() {
11015        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
11016        assert_eq!(2, pages.len());
11017        assert_eq!(Some(2), pages[1]["id"].as_i64());
11018    }
11019
11020    #[test]
11021    fn concatenated_pages_from_an_older_gh_are_read_too() {
11022        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
11023        assert_eq!(2, pages.len());
11024    }
11025
11026    /// A comment body containing a bracket pair used to split the payload into
11027    /// two invalid halves, so no state comment was found and a resume silently
11028    /// started from round one.
11029    #[test]
11030    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
11031        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
11032        let pages = parse_comment_pages(text);
11033        assert_eq!(2, pages.len(), "{pages:?}");
11034        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
11035    }
11036
11037    #[test]
11038    fn empty_output_is_no_comments_not_a_panic() {
11039        assert!(parse_comment_pages("").is_empty());
11040        assert!(parse_comment_pages("   ").is_empty());
11041        assert!(parse_comment_pages("[]").is_empty());
11042    }
11043
11044    #[test]
11045    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
11046        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
11047    }
11048
11049    #[test]
11050    fn a_write_postcheck_rejects_truncated_comment_pages() {
11051        let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
11052        assert!(
11053            error.to_string().contains("unexpected comment pages"),
11054            "{error}"
11055        );
11056    }
11057
11058    #[test]
11059    fn a_write_postcheck_rejects_empty_or_non_array_output() {
11060        assert!(try_parse_comment_pages("").is_err());
11061        assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
11062        assert!(try_parse_comment_pages("[]").is_ok());
11063    }
11064
11065    #[test]
11066    fn state_is_found_in_the_last_matching_comment() {
11067        let payload = |round: u32| {
11068            format!(
11069                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11070            )
11071        };
11072        let text = serde_json::to_string(&serde_json::json!([
11073            {"id": 1, "body": payload(1)},
11074            {"id": 2, "body": "looks good to me"},
11075            {"id": 3, "body": payload(5)},
11076        ]))
11077        .unwrap();
11078        let pages = parse_comment_pages(&text);
11079        let last = pages
11080            .iter()
11081            .rev()
11082            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
11083            .unwrap();
11084        assert_eq!(5, last.round);
11085    }
11086}
11087
11088#[cfg(test)]
11089mod linked_pr_tests {
11090    use super::*;
11091
11092    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
11093    /// It carries an id and a whole repository object per linked issue, and a
11094    /// parser that chokes on those reports "no pull request", which is the one
11095    /// answer that makes spar implement over the top of somebody's work.
11096    const REAL_PAYLOAD: &str = r#"[
11097      {"number":14252,"title":"fix: reject leading-dash branch names",
11098       "url":"https://github.com/cli/cli/pull/14252",
11099       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
11100         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11101         "url":"https://github.com/cli/cli/issues/14238"}]},
11102      {"number":14217,"title":"another change",
11103       "url":"https://github.com/cli/cli/pull/14217",
11104       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
11105         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11106         "url":"https://github.com/cli/cli/issues/9761"}]},
11107      {"number":14200,"title":"unlinked work",
11108       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
11109    ]"#;
11110
11111    #[test]
11112    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
11113        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
11114        assert_eq!(14252, pr.number);
11115        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
11116    }
11117
11118    #[test]
11119    fn the_right_pr_is_picked_out_of_several() {
11120        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
11121    }
11122
11123    #[test]
11124    fn an_issue_nobody_is_working_on_finds_nothing() {
11125        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
11126    }
11127
11128    #[test]
11129    fn an_unlinked_pr_is_never_matched() {
11130        // 14200 closes nothing, so no issue number should ever return it.
11131        for issue in [14200, 0, 1] {
11132            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
11133                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
11134            }
11135        }
11136    }
11137
11138    #[test]
11139    fn empty_or_broken_output_is_none_rather_than_a_panic() {
11140        assert!(find_linked_pr("", 1).is_none());
11141        assert!(find_linked_pr("[]", 1).is_none());
11142        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
11143        assert!(find_linked_pr("[{\"number\":", 1).is_none());
11144    }
11145
11146    /// "gh could not answer" and "nobody is working on it" are different
11147    /// answers, and only one of them means implement from scratch.
11148    ///
11149    /// The parse used to fold both into `None`, so an authentication failure or
11150    /// a rate limit read as an empty list and spar opened a second pull request
11151    /// for an issue somebody already had work open on.
11152    #[test]
11153    fn an_unreadable_list_is_not_an_empty_list() {
11154        assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
11155        assert!(linked_pr_rows("[{\"number\":").is_none());
11156        assert!(linked_pr_rows("").is_none());
11157
11158        let empty = linked_pr_rows("[]").expect("an empty list is an answer");
11159        assert!(empty.is_empty());
11160        assert!(pick_linked_pr(&empty, 14238).is_none());
11161
11162        let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
11163        assert_eq!(3, listed.len());
11164        assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
11165    }
11166
11167    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
11168    #[test]
11169    fn pr_view_reads_the_cross_repository_flag() {
11170        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
11171                       "baseRefName":"main","state":"OPEN",
11172                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
11173        let pr: PrView = serde_json::from_str(json).unwrap();
11174        assert!(pr.is_cross_repository);
11175        assert!(pr.is_open());
11176
11177        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
11178        assert!(
11179            !serde_json::from_str::<PrView>(&same_repo)
11180                .unwrap()
11181                .is_cross_repository
11182        );
11183    }
11184}
11185
11186#[cfg(test)]
11187mod min_number_tests {
11188    /// The floor is applied before the cap, which is the order that matters.
11189    /// spar takes the *lowest* numbered open items, so a repository with a tail
11190    /// of old issues would otherwise spend its whole run in the tail: the cap
11191    /// would be filled by the oldest items and the floor would never be
11192    /// reached. Filtering first is what makes the setting do anything.
11193    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
11194        let mut numbers: Vec<i64> = open.to_vec();
11195        numbers.sort_unstable();
11196        if min_number > 0 {
11197            numbers.retain(|n| *n >= min_number);
11198        }
11199        numbers.truncate(limit);
11200        numbers
11201    }
11202
11203    #[test]
11204    fn the_floor_is_applied_before_the_cap_not_after() {
11205        let open = [12, 13, 14, 480, 481, 482];
11206        assert_eq!(vec![480, 481], pick(&open, 2, 480));
11207        // Capping first would have returned the two oldest and then filtered
11208        // them all away, leaving nothing.
11209        assert!(!pick(&open, 2, 480).is_empty());
11210    }
11211
11212    #[test]
11213    fn no_floor_keeps_the_old_behaviour() {
11214        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
11215    }
11216
11217    #[test]
11218    fn the_floor_is_inclusive() {
11219        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
11220    }
11221
11222    #[test]
11223    fn a_floor_above_everything_open_yields_nothing() {
11224        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
11225    }
11226}
11227
11228#[cfg(test)]
11229mod review_record_tests {
11230    use super::*;
11231    use serde_json::json;
11232
11233    #[test]
11234    fn prune_state_knows_which_files_belong_to_a_pull_request() {
11235        assert_eq!(Some(12), state_file_number("pr-12.json"));
11236        assert_eq!(Some(12), state_file_number("review-12.json"));
11237        assert_eq!(Some(12), state_file_number("checkin-12.json"));
11238        for shared in [
11239            "spend.json",
11240            "implementors.json",
11241            "contested.json",
11242            "listen.json",
11243            "pr-.json",
11244            "pr-12.json.tmp",
11245            "pr-x.json",
11246        ] {
11247            assert_eq!(None, state_file_number(shared), "{shared}");
11248        }
11249    }
11250
11251    #[test]
11252    fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
11253        let reviews = vec![
11254            json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
11255            json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
11256        ];
11257        assert!(has_viewer_review(&reviews, "me", "the body", "aaa"));
11258        assert!(!has_viewer_review(&reviews, "me", "other", "aaa"));
11259        assert!(!has_viewer_review(&reviews, "alice", "the body", "aaa"));
11260    }
11261
11262    /// A clean review says the same thing on every commit. Taking the approval
11263    /// of an older commit for this one left the new head unapproved while the
11264    /// record said it was.
11265    #[test]
11266    fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
11267        let older = vec![
11268            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
11269        ];
11270        assert!(!has_viewer_review(&older, "me", "the body", "bbb"));
11271        let dismissed = vec![
11272            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
11273        ];
11274        assert!(!has_viewer_review(&dismissed, "me", "the body", "bbb"));
11275    }
11276
11277    #[test]
11278    fn a_response_splits_into_status_headers_and_body() {
11279        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";
11280        let resp = parse_api_response(text).expect("a response");
11281        assert_eq!(200, resp.status);
11282        assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
11283        assert_eq!(
11284            Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
11285            resp.headers.get("last-modified")
11286        );
11287        assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
11288
11289        let not_modified =
11290            parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
11291        assert_eq!(304, not_modified.status);
11292        assert!(not_modified.body.is_empty());
11293
11294        assert_eq!(None, parse_api_response(""));
11295        assert_eq!(None, parse_api_response("gh: command not found\n"));
11296    }
11297
11298    #[test]
11299    fn review_requests_are_read_off_the_timeline_with_who_and_when() {
11300        let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
11301            {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
11302            {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
11303            {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
11304            {}
11305        ]}}}}})
11306        .to_string();
11307        let made = parse_review_requests(&text).expect("requests");
11308        assert_eq!(2, made.len());
11309        assert_eq!("2026-10-20T10:00:00Z", made[0].at);
11310        assert_eq!("viewer", made[0].who.login);
11311        assert!(made[0].who.slug.is_empty());
11312        assert_eq!("2026-10-20T11:00:00Z", made[1].at);
11313        assert!(made[1].who.login.is_empty());
11314        assert_eq!("example/ours", made[1].who.slug);
11315
11316        let none =
11317            json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
11318        assert_eq!(
11319            Some(0),
11320            parse_review_requests(&none.to_string()).map(|m| m.len())
11321        );
11322        assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
11323        assert!(parse_review_requests("gh: not json").is_none());
11324    }
11325
11326    #[test]
11327    fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
11328        let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
11329        let error = crate::error::SparError::call_failed("gh: boom");
11330        assert!(
11331            reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(landed)).is_ok()
11332        );
11333
11334        let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
11335        let err = reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(missing))
11336            .unwrap_err();
11337        assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
11338
11339        let unreadable = Err(crate::error::SparError::new("no network"));
11340        let err = reconcile_review_post(5, "the body", "me", "aaa", error, unreadable).unwrap_err();
11341        assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
11342        assert!(err.to_string().contains("could not be verified"), "{err}");
11343    }
11344}