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, InlineComment, Issue, IssueRef, ItemKind, LiveHead,
21    PersistedState, PrRef, 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
1268/// The body of a review that carries line comments, for `gh api --input -`.
1269///
1270/// Every comment is on the new side of the diff, which is the only side a
1271/// finding about the branch is ever about.
1272fn review_payload(commit: &str, event: &str, body: &str, comments: &[InlineComment]) -> Value {
1273    let comments: Vec<Value> = comments
1274        .iter()
1275        .map(|c| {
1276            let mut row = serde_json::json!({
1277                "path": c.path,
1278                "line": c.line,
1279                "side": "RIGHT",
1280                "body": c.body,
1281            });
1282            if let Some(start) = c.start_line {
1283                row["start_line"] = serde_json::json!(start);
1284                row["start_side"] = serde_json::json!("RIGHT");
1285            }
1286            row
1287        })
1288        .collect();
1289    serde_json::json!({
1290        "commit_id": commit,
1291        "event": event,
1292        "body": body,
1293        "comments": comments,
1294    })
1295}
1296
1297/// Whether GitHub looked at a write and refused it, rather than failing to
1298/// answer. A refusal is final, so the same write changed is safe to send; a
1299/// timeout or a gateway error says nothing about whether the first one landed.
1300fn refused(e: &crate::error::SparError) -> bool {
1301    e.to_string().contains("(HTTP 422)")
1302}
1303
1304/// The lines of each file a pull request's diff shows on its new side, as
1305/// first and last line of each hunk. GitHub takes a line comment only on one
1306/// of these, and refuses the whole review over one that is not.
1307pub type Hunks = BTreeMap<String, Vec<(u32, u32)>>;
1308
1309/// Read the hunks out of a unified diff.
1310///
1311/// A hunk's lines are counted off against its header rather than recognised by
1312/// their first character, so an added line that itself begins `+++ ` or `@@ `
1313/// is content and not the start of something. A path git had to quote is left
1314/// out: what is between the quotes is escaped, not a path GitHub would match.
1315fn new_side_hunks(diff: &str) -> Hunks {
1316    let mut out = Hunks::new();
1317    let mut path: Option<String> = None;
1318    let (mut old_left, mut new_left) = (0u32, 0u32);
1319    for line in diff.lines() {
1320        if old_left > 0 || new_left > 0 {
1321            match line.as_bytes().first() {
1322                Some(b'+') => new_left = new_left.saturating_sub(1),
1323                Some(b'-') => old_left = old_left.saturating_sub(1),
1324                Some(b'\\') => {}
1325                _ => {
1326                    old_left = old_left.saturating_sub(1);
1327                    new_left = new_left.saturating_sub(1);
1328                }
1329            }
1330            continue;
1331        }
1332        if line.starts_with("diff --git ") {
1333            path = None;
1334        } else if let Some(rest) = line.strip_prefix("+++ ") {
1335            // git ends the line with a tab when the path has a space in it,
1336            // so that a patch tool can tell where the name stops.
1337            let rest = if rest.contains(' ') {
1338                rest.strip_suffix('\t').unwrap_or(rest)
1339            } else {
1340                rest
1341            };
1342            path = rest
1343                .strip_prefix("b/")
1344                .filter(|p| !p.is_empty())
1345                .map(str::to_string);
1346        } else if let Some(((_, old_len), (start, len))) = hunk_header(line) {
1347            old_left = old_len;
1348            new_left = len;
1349            if let (Some(path), true) = (&path, len > 0 && start > 0) {
1350                out.entry(path.clone())
1351                    .or_default()
1352                    .push((start, start + len - 1));
1353            }
1354        }
1355    }
1356    out
1357}
1358
1359/// `@@ -a,b +c,d @@`, as `((a, b), (c, d))`. A count left out is one.
1360fn hunk_header(line: &str) -> Option<((u32, u32), (u32, u32))> {
1361    let ranges = line.strip_prefix("@@ -")?.split_once(" @@")?.0;
1362    let (old, new) = ranges.split_once(" +")?;
1363    let range = |s: &str| -> Option<(u32, u32)> {
1364        match s.split_once(',') {
1365            Some((start, len)) => Some((start.parse().ok()?, len.parse().ok()?)),
1366            None => Some((s.parse().ok()?, 1)),
1367        }
1368    };
1369    Some((range(old)?, range(new)?))
1370}
1371
1372fn has_exact_comment(comments: &[Value], body: &str) -> bool {
1373    comments.iter().any(|comment| {
1374        comment
1375            .get("body")
1376            .and_then(Value::as_str)
1377            .is_some_and(|seen| seen == body)
1378    })
1379}
1380
1381fn reconcile_comment_post(
1382    number: i64,
1383    body: &str,
1384    post_error: crate::error::SparError,
1385    comments: Result<Vec<Value>>,
1386) -> Result<()> {
1387    match comments {
1388        Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
1389        Ok(_) => Err(post_error),
1390        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1391            "could not comment on #{number}. {} The result could not be verified: {}",
1392            post_error.last_line(),
1393            read_error.last_line()
1394        ))),
1395    }
1396}
1397
1398fn reconcile_issue_edit(
1399    number: i64,
1400    wanted: &str,
1401    edit_error: crate::error::SparError,
1402    observed: Result<String>,
1403) -> Result<()> {
1404    match observed {
1405        Ok(body) if body == wanted => Ok(()),
1406        Ok(_) => Err(spar_err!(
1407            "could not rewrite the body of #{number}. {}",
1408            edit_error.last_line()
1409        )),
1410        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1411            "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1412            edit_error.last_line(),
1413            read_error.last_line()
1414        ))),
1415    }
1416}
1417
1418fn issue_url_has_number(url: &str) -> bool {
1419    url.trim()
1420        .rsplit('/')
1421        .next()
1422        .and_then(|tail| tail.parse::<i64>().ok())
1423        .is_some_and(|number| number > 0)
1424}
1425
1426fn reconcile_issue_creation(
1427    title: &str,
1428    created: Result<String>,
1429    found: Result<Option<ExistingIssue>>,
1430) -> Result<String> {
1431    match (created, found) {
1432        (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1433        (_, Ok(Some(issue))) => Ok(issue.url),
1434        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1435            "issue creation reported success but no matching issue was found for {title:?}"
1436        ))),
1437        (Err(create), Ok(None)) => Err(spar_err!(
1438            "could not file issue {title:?}. {}",
1439            create.last_line()
1440        )),
1441        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1442            "issue creation reported success for {title:?}, but it could not be verified. {}",
1443            check.last_line()
1444        ))),
1445        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1446            "could not file issue {title:?}. {} The result could not be verified: {}",
1447            create.last_line(),
1448            check.last_line()
1449        ))),
1450    }
1451}
1452
1453fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1454    if output.trim().is_empty() {
1455        return Ok(None);
1456    }
1457    for line in output.lines() {
1458        let mut fields = line.split_whitespace();
1459        let oid = fields.next().unwrap_or_default();
1460        let name = fields.next().unwrap_or_default();
1461        if name == remote_ref && !oid.is_empty() {
1462            return Ok(Some(oid.to_string()));
1463        }
1464    }
1465    Err(spar_err!(
1466        "origin returned an unexpected ref listing for {remote_ref}"
1467    ))
1468}
1469
1470fn reconcile_failed_split_push(
1471    branch: &str,
1472    push_error: crate::error::SparError,
1473    local: Result<String>,
1474    remote: Result<String>,
1475) -> std::result::Result<(), SplitPushError> {
1476    let remote_ref = format!("refs/heads/{branch}");
1477    match (local, remote) {
1478        (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1479            Ok(Some(oid)) if oid == local.trim() => Ok(()),
1480            Ok(_) => Err(SplitPushError::new(
1481                format!(
1482                    "could not create origin/{branch}. {} The remote branch is absent or points \
1483                     somewhere else. Nothing was overwritten.",
1484                    push_error.last_line()
1485                ),
1486                false,
1487            )),
1488            Err(check) => Err(SplitPushError::new(
1489                format!(
1490                    "could not confirm whether origin/{branch} was created. {} The remote result \
1491                     could not be verified: {}",
1492                    push_error.last_line(),
1493                    check.last_line()
1494                ),
1495                true,
1496            )),
1497        },
1498        (local, remote) => {
1499            let check = match (local, remote) {
1500                (Err(local), Err(remote)) => format!(
1501                    "the local commit could not be read: {}; origin could not be read: {}",
1502                    local.last_line(),
1503                    remote.last_line()
1504                ),
1505                (Err(local), _) => {
1506                    format!("the local commit could not be read: {}", local.last_line())
1507                }
1508                (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1509                _ => unreachable!(),
1510            };
1511            Err(SplitPushError::new(
1512                format!(
1513                    "could not confirm whether origin/{branch} was created. {} The result could \
1514                     not be verified because {check}",
1515                    push_error.last_line()
1516                ),
1517                true,
1518            ))
1519        }
1520    }
1521}
1522
1523impl Repo {
1524    pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1525        let root =
1526            std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1527        // A linked worktree has a `.git` file rather than a directory, and a
1528        // bare-ish layout can have neither, so ask git instead of guessing.
1529        let inside = proc::run_str(
1530            &["git", "rev-parse", "--is-inside-work-tree"],
1531            &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1532        )
1533        .unwrap_or_default();
1534        if inside.trim() != "true" {
1535            bail!("not a git repository: {}", root.display());
1536        }
1537        let repo = Self {
1538            root,
1539            style: cfg.style.clone(),
1540            branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1541            state_store: cfg.loop_cfg.state_store,
1542            followups: cfg.loop_cfg.followups,
1543            drafts: cfg.loop_cfg.drafts,
1544            fork_when_denied: cfg.loop_cfg.fork_when_denied,
1545            fork: OnceLock::new(),
1546            viewer: OnceLock::new(),
1547            checkpoints: Mutex::new(BTreeMap::new()),
1548            writes: WriteStats::default(),
1549        };
1550        repo.self_exclude();
1551        Ok(repo)
1552    }
1553
1554    /// Keep spar's own scratch directories out of the target repo's
1555    /// `git status`.
1556    ///
1557    /// Written to `.git/info/exclude`, never to a tracked `.gitignore`: this is
1558    /// somebody else's repository and spar has no business committing to it.
1559    /// Best effort and silent on failure, because a read-only git directory is
1560    /// not a reason to abandon a run.
1561    fn self_exclude(&self) {
1562        let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1563        let git_dir = git_dir.trim();
1564        if git_dir.is_empty() {
1565            return;
1566        }
1567        let path = Path::new(git_dir).join("info").join("exclude");
1568        let existing = std::fs::read_to_string(&path).unwrap_or_default();
1569
1570        // The marker too. It is written into whichever directory the call ran
1571        // in, which for a shared checkout is the repository root, and an
1572        // untracked file there is one `git add -A` away from being committed.
1573        // The error names its path, which is how a person is meant to find it.
1574        let wanted = [
1575            format!("/{WORKTREE_DIR}/"),
1576            format!("/{STATE_DIR}/"),
1577            format!("{RECOVERY_MARKER_PREFIX}*"),
1578        ];
1579        let missing: Vec<&String> = wanted
1580            .iter()
1581            .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1582            .collect();
1583        if missing.is_empty() {
1584            return;
1585        }
1586
1587        use std::io::Write;
1588        if let Some(parent) = path.parent() {
1589            let _ = std::fs::create_dir_all(parent);
1590        }
1591        let mut block = String::new();
1592        if !existing.is_empty() && !existing.ends_with('\n') {
1593            block.push('\n');
1594        }
1595        block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1596        for line in missing {
1597            block.push_str(line);
1598            block.push('\n');
1599        }
1600        if let Ok(mut file) = std::fs::OpenOptions::new()
1601            .create(true)
1602            .append(true)
1603            .open(&path)
1604        {
1605            let _ = file.write_all(block.as_bytes());
1606        }
1607    }
1608
1609    pub fn root(&self) -> &Path {
1610        &self.root
1611    }
1612
1613    pub(crate) fn write_summary(&self) -> WriteSummary {
1614        WriteSummary {
1615            attempted: self.writes.attempted.load(Ordering::Relaxed),
1616            failed: self.writes.failed.load(Ordering::Relaxed),
1617        }
1618    }
1619
1620    pub(crate) fn record_write<T, E>(
1621        &self,
1622        result: std::result::Result<T, E>,
1623    ) -> std::result::Result<T, E> {
1624        self.record_write_outcome(result.is_err());
1625        result
1626    }
1627
1628    pub(crate) fn record_failed_write<T, E>(
1629        &self,
1630        result: std::result::Result<T, E>,
1631    ) -> std::result::Result<T, E> {
1632        if result.is_err() {
1633            self.record_write_outcome(true);
1634        }
1635        result
1636    }
1637
1638    fn record_write_outcome(&self, failed: bool) {
1639        self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1640        if failed {
1641            self.writes.failed.fetch_add(1, Ordering::Relaxed);
1642        }
1643    }
1644
1645    // -- gates ------------------------------------------------------------
1646
1647    /// Scrub, then verify. A leak here reaches GitHub, so it is a hard error
1648    /// rather than a warning: silent partial compliance is how a style rule
1649    /// erodes over a long run.
1650    pub fn clean(&self, text: &str) -> Result<String> {
1651        let out = style::scrub(text, &self.style);
1652        let bad = style::violations(&out, &self.style);
1653        if !bad.is_empty() {
1654            bail!(
1655                "style gate could not clean text ({}): {}",
1656                bad.join(", "),
1657                style::clip(&out, 300)
1658            );
1659        }
1660        Ok(out)
1661    }
1662
1663    /// Clean, and hold to a length budget. For anything a model wrote.
1664    pub fn clean_body(&self, text: &str) -> Result<String> {
1665        self.clean(&style::body(text, &self.style))
1666    }
1667
1668    /// The same, with an issue's far larger budget and its exemption for code.
1669    pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1670        self.clean(&style::issue_body(text, &self.style))
1671    }
1672
1673    /// The single transform every outbound title goes through.
1674    ///
1675    /// Scrub first, clip second, and never the other way round. Clipping first
1676    /// lets the scrub lengthen the result past the budget (an em dash becomes
1677    /// two characters), so a second pass would clip again and produce a
1678    /// different string. That broke follow-up deduplication silently: the
1679    /// lookup searched for one title while GitHub had stored another, no match
1680    /// was ever found, and a fresh duplicate issue was filed every review
1681    /// round. Doing it in this order makes the transform idempotent, which the
1682    /// tests assert.
1683    pub fn clean_title(&self, text: &str) -> Result<String> {
1684        Ok(style::title(&self.clean(text)?, &self.style))
1685    }
1686
1687    pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1688        let title = self.record_failed_write(self.clean_title(text))?;
1689        if title.trim().is_empty() {
1690            return self.record_failed_write(Err(spar_err!(
1691                "nothing left of the title after cleaning it"
1692            )));
1693        }
1694        Ok(title)
1695    }
1696
1697    pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1698        if self.followups == Followups::Issues {
1699            self.clean_nonempty_title_for_write(text)
1700        } else {
1701            self.clean_title(text)
1702        }
1703    }
1704
1705    // -- git --------------------------------------------------------------
1706
1707    fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1708        ExecOpts::new()
1709            .cwd(cwd.unwrap_or(&self.root))
1710            .check(check)
1711            .timeout_secs(600)
1712    }
1713
1714    pub fn git(&self, args: &[&str]) -> Result<String> {
1715        self.git_at(None, args)
1716    }
1717
1718    pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1719        let argv = git_without_maintenance_argv(args);
1720        proc::run(&argv, &self.git_opts(cwd, true))
1721    }
1722
1723    /// Run a parent-side Git operation without inherited background helpers.
1724    ///
1725    /// Editing calls are untrusted. Once one returns, status, staging, and
1726    /// committing happen in this process, so an inherited fsmonitor or automatic
1727    /// maintenance command must not become a way to execute outside its sandbox.
1728    fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1729        let argv = git_without_automation_argv(args);
1730        proc::run(
1731            &argv,
1732            &self.git_opts(Some(cwd), true).stop_descendants(true),
1733        )
1734    }
1735
1736    fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1737        let argv = git_without_automation_argv(args);
1738        proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1739            .map(|output| output.ok())
1740    }
1741
1742    /// Run git, tolerating failure. Returns whatever landed on stdout.
1743    pub fn git_try(&self, args: &[&str]) -> String {
1744        self.git_try_at(None, args)
1745    }
1746
1747    pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1748        let argv = git_without_maintenance_argv(args);
1749        proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1750    }
1751
1752    /// The base branch the remote actually points at, rather than assuming
1753    /// `main`. Falls back to the configured value when there is no origin.
1754    pub fn default_branch(&self, configured: &str) -> String {
1755        let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1756        match refname.trim().rsplit('/').next() {
1757            Some(name) if !name.is_empty() => name.to_string(),
1758            _ => configured.to_string(),
1759        }
1760    }
1761
1762    // -- branch naming and ownership --------------------------------------
1763    //
1764    // Branch names default to `issue-N`, which is exactly what a person would
1765    // name a branch by hand. Ownership therefore cannot be inferred from the
1766    // name, so every branch spar creates is recorded and cleanup only ever
1767    // touches what is in that record.
1768
1769    pub fn branch_for_issue(&self, issue: i64) -> String {
1770        format!("{}issue-{issue}", self.branch_prefix)
1771    }
1772
1773    pub fn branch_for_pr(&self, number: i64) -> String {
1774        format!("{}pr-{number}", self.branch_prefix)
1775    }
1776
1777    /// One part of a split, numbered from 1 within its parent.
1778    ///
1779    /// Its own namespace rather than `issue-N`, because the parts of a split
1780    /// pull request have no issue of their own and would otherwise collide with
1781    /// the branch of the issue that happens to share the parent's number.
1782    ///
1783    /// The name a part is tried on first. `worktree_for_split` may end up on a
1784    /// suffixed one, because this name is not free forever.
1785    pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1786        format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1787    }
1788
1789    fn ledger_path(&self) -> PathBuf {
1790        self.root.join(STATE_DIR).join("branches.json")
1791    }
1792
1793    pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1794        std::fs::read_to_string(self.ledger_path())
1795            .ok()
1796            .and_then(|text| serde_json::from_str(&text).ok())
1797            .unwrap_or_default()
1798    }
1799
1800    pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1801        let mut data = self.known_branches();
1802        data.insert(
1803            branch.to_string(),
1804            BranchRecord {
1805                kind: kind.to_string(),
1806                number,
1807            },
1808        );
1809        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1810            logdim!("could not record branch {branch}: {e}");
1811        }
1812    }
1813
1814    pub fn forget_branch(&self, branch: &str) {
1815        let mut data = self.known_branches();
1816        if data.remove(branch).is_none() {
1817            return;
1818        }
1819        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1820            logdim!("could not update the branch record: {e}");
1821        }
1822    }
1823
1824    // -- worktrees --------------------------------------------------------
1825
1826    fn worktree_path(&self, name: &str) -> PathBuf {
1827        self.root.join(WORKTREE_DIR).join(name)
1828    }
1829
1830    /// Isolate an issue so a failed run cannot poison the next one's base.
1831    pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1832        let branch = self.branch_for_issue(issue);
1833        let path = self.worktree_path(&format!("issue-{issue}"));
1834
1835        self.refuse_issue_branch_rebuild(issue, base)?;
1836        self.refuse_dirty_worktree(
1837            &path,
1838            &format!("worktree for issue #{issue}"),
1839            Disposable::BuildOutput,
1840        )?;
1841
1842        if !self.branch_deletion_is_safe(&branch)? {
1843            bail!(
1844                "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1845                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1846                 before retrying."
1847            );
1848        }
1849
1850        if !self.remove_worktree_at(&path)? {
1851            bail!(
1852                "the existing worktree for issue #{issue} could not be removed safely. Its \
1853                 branch was kept."
1854            );
1855        }
1856        if !self.delete_branch_if_safe(&branch)? {
1857            bail!(
1858                "the existing branch {branch} changed or remained checked out while its \
1859                 worktree was being rebuilt. It was kept."
1860            );
1861        }
1862
1863        if let Some(parent) = path.parent() {
1864            std::fs::create_dir_all(parent)
1865                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1866        }
1867
1868        let path_str = path.display().to_string();
1869        let remote_start = format!("origin/{base}");
1870        let created = self
1871            .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1872            .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1873
1874        // Recorded on both paths: an unrecorded branch is one cleanup will
1875        // never remove, and the fallback creates a branch just the same.
1876        created.map_err(|e| {
1877            spar_err!(
1878                "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1879                 and does `origin` exist?",
1880                e.last_line()
1881            )
1882        })?;
1883        self.record_branch(&branch, "issue", issue);
1884        Ok((path, branch))
1885    }
1886
1887    /// Refuse to reset the local or remote branch assigned to an issue when it
1888    /// carries work that no pull request preserves.
1889    ///
1890    /// Both linked-worktree mode and shared-checkout mode rebuild the same
1891    /// branch name. Keeping the guard here prevents either path from silently
1892    /// replacing recovery commits left by an earlier run.
1893    pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1894        let branch = self.branch_for_issue(issue);
1895        let base_remote_ref = format!("refs/heads/{base}");
1896        let base_tracking_ref = format!("refs/remotes/origin/{base}");
1897        let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1898        self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1899            .map_err(|e| {
1900                spar_err!(
1901                    "could not refresh origin/{base} before checking issue #{issue}: {}",
1902                    e.last_line()
1903                )
1904            })?;
1905
1906        if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
1907            let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
1908            if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
1909                bail!(
1910                    "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
1911                     pull request accounts for them. Rebuilding it would force push over that \
1912                     work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
1913                     it, or delete it with `git push origin --delete {branch}` if the remote \
1914                     branch is no longer needed."
1915                );
1916            }
1917        }
1918
1919        // The same question of the fork a refused push would go to, asked here
1920        // rather than after an agent has spent its call implementing.
1921        if self.fork_when_denied && self.origin_refuses_push() {
1922            match self.existing_fork() {
1923                Ok(Some(fork)) => {
1924                    self.fork_branch_lease(&fork, &branch, base)?;
1925                }
1926                Ok(None) => {}
1927                Err(e) => logdim!("{e} It is checked again before anything is pushed to it."),
1928            }
1929        }
1930
1931        let local_ref = format!("refs/heads/{branch}");
1932        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1933            let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
1934            let recorded_pr = self
1935                .known_branches()
1936                .get(&branch)
1937                .is_some_and(|record| record.kind == "pr");
1938            let preserved = ahead == 0
1939                || if recorded_pr {
1940                    self.local_branch_is_preserved(&branch)?
1941                } else {
1942                    self.pull_request_holds(&branch, &local_ref, base)
1943                };
1944            if !preserved {
1945                let listed = self
1946                    .commit_lines(&self.root, &local_ref, base)
1947                    .iter()
1948                    .map(|line| format!("  {line}"))
1949                    .collect::<Vec<_>>()
1950                    .join("\n");
1951                bail!(
1952                    "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
1953                     no pull request preserves them. Rebuilding it would delete the only copy.\n\
1954                     {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
1955                     it, or delete it with `git branch -D {branch}` if it is stale."
1956                );
1957            }
1958        }
1959        Ok(())
1960    }
1961
1962    fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
1963        let live_ref = format!("refs/heads/{branch}");
1964        let tracking_ref = format!("refs/remotes/origin/{branch}");
1965        let listed = self
1966            .git(&["ls-remote", "--heads", "origin", &live_ref])
1967            .map_err(|e| {
1968                spar_err!(
1969                    "could not verify whether origin/{branch} still exists: {}",
1970                    e.last_line()
1971                )
1972            })?;
1973
1974        if remote_head_oid(&listed, &live_ref)?.is_some() {
1975            let refspec = format!("+{live_ref}:{tracking_ref}");
1976            self.git(&["fetch", "--no-tags", "origin", &refspec])
1977                .map_err(|e| {
1978                    spar_err!(
1979                        "origin/{branch} exists but its tracking ref could not be refreshed: {}",
1980                        e.last_line()
1981                    )
1982                })?;
1983            if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1984                bail!("origin/{branch} was fetched but its tracking ref is missing");
1985            }
1986            return Ok(Some(tracking_ref));
1987        }
1988
1989        if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1990            return Ok(None);
1991        }
1992        let expected = self
1993            .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
1994            .trim()
1995            .to_string();
1996        self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
1997            .map_err(|e| {
1998                spar_err!(
1999                    "could not discard stale origin/{branch} tracking ref safely: {}",
2000                    e.last_line()
2001                )
2002            })?;
2003        if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2004            bail!(
2005                "origin/{branch} changed while its stale tracking ref was being removed. It was \
2006                 kept."
2007            );
2008        }
2009        Ok(None)
2010    }
2011
2012    /// Whether a pull request from `branch` already holds every commit `refname`
2013    /// has beyond `base`.
2014    ///
2015    /// GitHub serves `refs/pull/N/head` for as long as the repository lives, so
2016    /// commits that reached a pull request outlive the branch they were pushed
2017    /// from. A matching branch name does not establish that on its own: an
2018    /// issue worked twice reuses the name, and the merged pull request from the
2019    /// first round says nothing about where the second round's commits are.
2020    fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
2021        self.prs_for_branch(branch)
2022            .iter()
2023            .any(|pr| self.pr_head_holds(pr.number, refname, base))
2024    }
2025
2026    fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
2027        let head = format!("refs/spar/pr-head/{number}");
2028        let refspec = format!("+refs/pull/{number}/head:{head}");
2029        if self.git(&["fetch", "origin", &refspec]).is_err() {
2030            return false;
2031        }
2032        let held = self.commits_held_by(refname, base, &head);
2033        self.git_try(&["update-ref", "-d", &head]);
2034        held
2035    }
2036
2037    pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
2038        let argv = vec![
2039            "git".to_string(),
2040            "merge-base".to_string(),
2041            "--is-ancestor".to_string(),
2042            older.to_string(),
2043            newer.to_string(),
2044        ];
2045        let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
2046        match out.code {
2047            0 => Ok(true),
2048            1 => Ok(false),
2049            _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
2050        }
2051    }
2052
2053    fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
2054        let head = format!("refs/spar/pr-head/{number}");
2055        let refspec = format!("+refs/pull/{number}/head:{head}");
2056        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2057            spar_err!(
2058                "could not verify the immutable head of PR #{number}: {}",
2059                e.last_line()
2060            )
2061        })?;
2062        let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
2063        self.git_try(&["update-ref", "-d", &head]);
2064        held
2065    }
2066
2067    fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
2068        let text = self.gh(&[
2069            "pr",
2070            "list",
2071            "--head",
2072            branch,
2073            "--state",
2074            "all",
2075            "--json",
2076            "number,url,title",
2077        ])?;
2078        serde_json::from_str(text.trim())
2079            .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
2080    }
2081
2082    fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
2083        let branch_ref = format!("refs/heads/{branch}");
2084        if record.kind == "pr" {
2085            return self.pr_head_contains_checked(record.number, &branch_ref);
2086        }
2087        let prs = self.branch_prs_checked(branch)?;
2088        if prs.is_empty() {
2089            return Ok(false);
2090        }
2091        for pr in prs {
2092            if self.pr_head_contains_checked(pr.number, &branch_ref)? {
2093                return Ok(true);
2094            }
2095        }
2096        Ok(false)
2097    }
2098
2099    fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
2100        let local_ref = format!("refs/heads/{branch}");
2101        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2102            return Ok(true);
2103        }
2104        let oid = self
2105            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2106            .trim()
2107            .to_string();
2108        let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
2109        if !durable_tip {
2110            let remote_ref = format!("refs/heads/{branch}");
2111            let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
2112            durable_tip = remote.lines().any(|line| {
2113                line.split_whitespace()
2114                    .next()
2115                    .is_some_and(|remote_oid| remote_oid == oid)
2116            });
2117        }
2118        if !durable_tip {
2119            if let Some(record) = self.known_branches().get(branch) {
2120                durable_tip = self.branch_is_preserved_checked(branch, record)?;
2121            }
2122        }
2123        if !durable_tip {
2124            return Ok(false);
2125        }
2126        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2127    }
2128
2129    /// Delete a branch only if its exact current tip and reflog are still safe.
2130    /// The expected old value makes a concurrent ref update fail instead of
2131    /// deleting work that appeared after the preservation check.
2132    fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
2133        let local_ref = format!("refs/heads/{branch}");
2134        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2135            return Ok(true);
2136        }
2137        let expected = self
2138            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2139            .trim()
2140            .to_string();
2141        if !self.branch_deletion_is_safe(branch)? {
2142            return Ok(false);
2143        }
2144        let checked_out = self
2145            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
2146            .lines()
2147            .any(|line| line == format!("branch {local_ref}"));
2148        if checked_out {
2149            return Ok(false);
2150        }
2151        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2152        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2153    }
2154
2155    fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
2156        let local_ref = review_ref(number);
2157        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2158            return Ok(true);
2159        }
2160        let oid = self
2161            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2162            .trim()
2163            .to_string();
2164        if !self.pr_head_contains_checked(number, &local_ref)? {
2165            return Ok(false);
2166        }
2167        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2168    }
2169
2170    fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
2171        let local_ref = review_ref(number);
2172        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2173            return Ok(true);
2174        }
2175        let expected = self
2176            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2177            .trim()
2178            .to_string();
2179        if !self.review_ref_deletion_is_safe(number)? {
2180            return Ok(false);
2181        }
2182        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2183        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2184    }
2185
2186    /// Whether `other` already contains every commit `branch` has beyond
2187    /// `base`. False when either ref fails to resolve, so a ref that is not
2188    /// there cannot vouch for anything.
2189    pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
2190        let range = format!("{}..{branch}", self.base_ref(&self.root, base));
2191        self.git_try(&["rev-list", "--count", &range, "--not", other])
2192            .trim()
2193            == "0"
2194    }
2195
2196    pub fn worktree_remove(&self, issue: i64) -> bool {
2197        let path = self.worktree_path(&format!("issue-{issue}"));
2198        match self.remove_worktree_at(&path) {
2199            Ok(removed) => removed,
2200            Err(error) => {
2201                logdim!(
2202                    "kept {} because removal did not reach a confirmed quiet point: {}",
2203                    path.display(),
2204                    error.last_line()
2205                );
2206                false
2207            }
2208        }
2209    }
2210
2211    /// Verify both the common Git directory and the worktree top level.
2212    ///
2213    /// A stale worktree entry is not ownership proof. An unrelated repository
2214    /// can later occupy the same path and must survive cleanup.
2215    fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
2216        let wanted = std::fs::canonicalize(path)
2217            .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
2218        // Every SPAR worktree path is built from the canonical repository root.
2219        // A different canonical path therefore means the final component or
2220        // one of its parents is a symlink. Passing that alias to `git worktree
2221        // remove` can delete the worktree at its real target.
2222        if wanted != path {
2223            return Ok(false);
2224        }
2225        let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
2226            let raw = PathBuf::from(value.trim());
2227            let joined = if raw.is_absolute() {
2228                raw
2229            } else {
2230                cwd.join(raw)
2231            };
2232            std::fs::canonicalize(&joined)
2233                .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
2234        };
2235        let expected =
2236            self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
2237        let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
2238        let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
2239        let expected = resolve(&self.root, &expected)?;
2240        let actual = resolve(path, &actual)?;
2241        let top = resolve(path, &top)?;
2242        Ok(expected == actual && top == wanted)
2243    }
2244
2245    /// Remove only a worktree that belongs to this repository.
2246    ///
2247    /// The path sits under a predictable directory, but that does not establish
2248    /// ownership. A clean independent repository at the same path must survive
2249    /// even when `git worktree remove` rejects it.
2250    fn remove_worktree_at_with_force(
2251        &self,
2252        path: &Path,
2253        force: bool,
2254        disposable: Disposable,
2255    ) -> Result<bool> {
2256        let existed = path.exists();
2257        if path.exists() {
2258            match self.worktree_belongs_to_repo(path) {
2259                Ok(true) => {}
2260                Ok(false) => {
2261                    logdim!(
2262                        "kept {} because it is not a worktree owned by this repository",
2263                        path.display()
2264                    );
2265                    return Ok(false);
2266                }
2267                Err(e) => {
2268                    logdim!(
2269                        "kept {} because its worktree ownership could not be verified: {}",
2270                        path.display(),
2271                        e.last_line()
2272                    );
2273                    return Ok(false);
2274                }
2275            }
2276            if !force {
2277                match self.has_recoverable_work(path, disposable) {
2278                    Ok(true) => {
2279                        logdim!(
2280                            "kept {} because it contains recoverable files or repository state",
2281                            path.display()
2282                        );
2283                        return Ok(false);
2284                    }
2285                    Err(e) => {
2286                        logdim!(
2287                            "kept {} because its recoverable state could not be checked: {}",
2288                            path.display(),
2289                            e.last_line()
2290                        );
2291                        return Ok(false);
2292                    }
2293                    Ok(false) => {}
2294                }
2295            }
2296        }
2297        // Git refuses to remove a worktree holding any untracked file, which is
2298        // the same question SPAR has just answered in more detail: under
2299        // `ReviewOutput` the checks above have already established that nothing
2300        // there is worth keeping. `--force` here is that answer being carried
2301        // out, not the check being skipped, which is what `force` means.
2302        let path_str = path.display().to_string();
2303        let command_ok = if force || disposable == Disposable::ReviewOutput {
2304            self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
2305        } else {
2306            self.git_try_without_automation(&["worktree", "remove", &path_str])?
2307        };
2308        Ok((command_ok || !existed) && !path.exists())
2309    }
2310
2311    fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
2312        self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
2313    }
2314
2315    /// Remove a review checkout that is about to be rebuilt from origin.
2316    fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
2317        self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
2318    }
2319
2320    /// Force removal is reserved for the explicit `clean --all` path.
2321    fn remove_worktree_at_force(&self, path: &Path) -> bool {
2322        match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
2323            Ok(removed) => removed,
2324            Err(error) => {
2325                logdim!(
2326                    "kept {} because removal did not reach a confirmed quiet point: {}",
2327                    path.display(),
2328                    error.last_line()
2329                );
2330                false
2331            }
2332        }
2333    }
2334
2335    /// Remove a worktree only after a caller has verified it is unchanged.
2336    ///
2337    /// There is deliberately no force fallback, so Git can still refuse a
2338    /// removal if tracked or non-ignored work appears after the final check.
2339    fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
2340        if path.exists() && !self.worktree_belongs_to_repo(path)? {
2341            bail!(
2342                "{} is not a worktree owned by this repository, so it was kept",
2343                path.display()
2344            );
2345        }
2346        if path.exists() && self.has_recoverable_work(path, disposable)? {
2347            bail!(
2348                "the verified worktree at {} contains recoverable files or repository state. It \
2349                 was kept.",
2350                path.display()
2351            );
2352        }
2353        let path_str = path.display().to_string();
2354        self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
2355            .map_err(|e| {
2356                e.with_message(format!(
2357                    "could not remove the verified worktree at {}: {}. It was kept.",
2358                    path.display(),
2359                    e.last_line()
2360                ))
2361            })?;
2362        Ok(!path.exists())
2363    }
2364
2365    fn refuse_dirty_worktree(
2366        &self,
2367        path: &Path,
2368        label: &str,
2369        disposable: Disposable,
2370    ) -> Result<()> {
2371        if !path.is_dir() {
2372            return Ok(());
2373        }
2374        let has_files = std::fs::read_dir(path)
2375            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2376            .next()
2377            .is_some();
2378        let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
2379            spar_err!(
2380                "could not verify whether the existing {label} at {} belongs to this repository, \
2381                 so it was kept: {}",
2382                path.display(),
2383                e.last_line()
2384            )
2385        })?;
2386        if !owned {
2387            if has_files {
2388                bail!(
2389                    "the existing {label} at {} is not a worktree owned by \
2390                     this repository. Refusing to remove it.",
2391                    path.display()
2392                );
2393            }
2394            return Ok(());
2395        }
2396        if !path.join(".git").exists() {
2397            if has_files {
2398                bail!(
2399                    "the existing {label} at {} is not a readable Git worktree and is not empty. \
2400                     Refusing to remove it.",
2401                    path.display()
2402                );
2403            }
2404            return Ok(());
2405        }
2406        let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2407            spar_err!(
2408                "could not verify whether the existing {label} at {} is clean, so it was kept: \
2409                 {}",
2410                path.display(),
2411                e.last_line()
2412            )
2413        })?;
2414        if dirty {
2415            bail!(
2416                "the existing {label} contains uncommitted changes or ignored files at {}. \
2417                 Rebuilding it would delete those files.\nCommit or recover them before running this \
2418                 command again, or use `spar clean --all` if they are not needed.",
2419                path.display()
2420            );
2421        }
2422        Ok(())
2423    }
2424
2425    /// Check an existing PR branch out into an isolated worktree, from
2426    /// wherever `target` says the branch is published.
2427    pub fn worktree_for_pr(&self, pr: &PrView, target: &PushTarget) -> Result<(PathBuf, String)> {
2428        let head = pr.head_ref_name.clone();
2429        if head.trim().is_empty() {
2430            bail!("PR #{} has no head branch to check out", pr.number);
2431        }
2432        let path = self.worktree_path(&format!("pr-{}", pr.number));
2433        let local = self.branch_for_pr(pr.number);
2434
2435        let unfetched = |e: crate::error::SparError| {
2436            spar_err!(
2437                "could not fetch the branch behind PR #{}: {}",
2438                pr.number,
2439                e.last_line()
2440            )
2441        };
2442        // What the worktree starts from, the ref behind it, and what to call
2443        // it when saying so.
2444        let (start, start_ref, shown) = match target {
2445            PushTarget::Origin => {
2446                self.git(&["fetch", "origin", &head]).map_err(unfetched)?;
2447                let start = format!("origin/{head}");
2448                (start.clone(), format!("refs/remotes/origin/{head}"), start)
2449            }
2450            // A fork has no remote here to track it, so its branch is
2451            // fetched into a ref of spar's own.
2452            PushTarget::Fork(fork) => {
2453                let start_ref = format!("refs/spar/fork/{head}");
2454                let refspec = format!("+refs/heads/{head}:{start_ref}");
2455                self.git(&["fetch", "--no-tags", &fork.url, &refspec])
2456                    .map_err(unfetched)?;
2457                (start_ref.clone(), start_ref, target.describe(&head))
2458            }
2459        };
2460        let local_ref = format!("refs/heads/{local}");
2461        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2462            let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2463            if unpushed > 0 {
2464                bail!(
2465                    "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2466                     on {shown}. Rebuilding it would delete their branch.\nInspect the worktree at \
2467                     {} and push or recover those commits before running this command again.",
2468                    pr.number,
2469                    path.display()
2470                );
2471            }
2472        }
2473        self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2474        self.refuse_dirty_worktree(
2475            &path,
2476            &format!("worktree for PR #{}", pr.number),
2477            Disposable::BuildOutput,
2478        )?;
2479        if !self.branch_deletion_is_safe(&local)? {
2480            bail!(
2481                "the existing branch {local} has a tip or reflog-only commit that no surviving \
2482                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2483                 before retrying."
2484            );
2485        }
2486        if !self.remove_worktree_at(&path)? {
2487            bail!(
2488                "the existing worktree for PR #{} could not be removed safely. Its branch was \
2489                 kept.",
2490                pr.number
2491            );
2492        }
2493        if !self.delete_branch_if_safe(&local)? {
2494            bail!(
2495                "the existing branch {local} changed or remained checked out while the PR \
2496                 worktree was being rebuilt. It was kept."
2497            );
2498        }
2499
2500        let path_str = path.display().to_string();
2501        self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2502        self.record_branch(&local, "pr", pr.number);
2503        Ok((path, head))
2504    }
2505
2506    /// A retry needs a fresh checkout, not permission to delete ignored data.
2507    /// Keep that data and its branch when all commits are already published.
2508    fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2509        if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2510            return Ok(());
2511        }
2512        let current =
2513            self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2514        if current.trim() != branch
2515            || self.has_uncommitted_changes(path)?
2516            || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2517        {
2518            return Ok(());
2519        }
2520        static NEXT: AtomicUsize = AtomicUsize::new(0);
2521        let (name, retained) = loop {
2522            let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2523            let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2524            let retained = self.worktree_path(&name);
2525            if !retained
2526                .try_exists()
2527                .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2528                && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2529            {
2530                break (name, retained);
2531            }
2532        };
2533        // Neither command overwrites an existing destination. The branch rename
2534        // preserves its reflog, and the worktree move preserves every file.
2535        self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2536        if let Err(error) = self.git_at_without_automation(
2537            &self.root,
2538            &[
2539                "worktree",
2540                "move",
2541                &path.display().to_string(),
2542                &retained.display().to_string(),
2543            ],
2544        ) {
2545            // A locked worktree or one with submodules cannot always be moved.
2546            // Restore the branch name so a failed move does not strand a retry.
2547            let rollback =
2548                self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2549            let detail = rollback
2550                .err()
2551                .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2552                .unwrap_or_default();
2553            bail!(
2554                "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2555                retained.display(),
2556                error.last_line()
2557            );
2558        }
2559        logdim!(
2560            "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2561            retained.display()
2562        );
2563        Ok(())
2564    }
2565
2566    /// Check a pull request's head out read only, detached, with no branch.
2567    ///
2568    /// Fetches `refs/pull/N/head`, which GitHub serves for every pull request
2569    /// including one from a fork whose branch is not in this repository at all.
2570    /// That is what makes reviewing an outside contribution possible when
2571    /// pushing to it is not.
2572    ///
2573    /// Detached on purpose. Review only mode has nothing to push, and a branch
2574    /// would only invite something to try.
2575    pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2576        let path = self.worktree_path(&format!("review-{number}"));
2577        let local_ref = review_ref(number);
2578        let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2579
2580        self.refuse_review_worktree_changes(number)?;
2581
2582        if let Some(parent) = path.parent() {
2583            std::fs::create_dir_all(parent)
2584                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2585        }
2586        // Removed before the fetch, while `local_ref` still holds the head
2587        // this checkout is at. A force push moves the ref off that commit,
2588        // and the checkout's reflog would then name a commit no ref holds,
2589        // which the removal check rightly keeps as local work.
2590        if !self.remove_review_worktree_at(&path)? {
2591            bail!(
2592                "the existing review worktree for PR #{number} could not be removed safely. Its \
2593                 reference was kept."
2594            );
2595        }
2596
2597        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2598            spar_err!(
2599                "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2600                 every pull request, so this usually means the number is wrong or `origin` does \
2601                 not point at the repository the PR is on.",
2602                e.last_line()
2603            )
2604        })?;
2605
2606        let path_str = path.display().to_string();
2607        self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2608        Ok(path)
2609    }
2610
2611    fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2612        let path = self.worktree_path(&format!("review-{number}"));
2613        if !path.is_dir() {
2614            return Ok(());
2615        }
2616        let local_ref = review_ref(number);
2617        if !self.worktree_belongs_to_repo(&path)? {
2618            return Ok(());
2619        }
2620        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2621            bail!(
2622                "the existing review worktree for PR #{number} has no recorded head at \
2623                 {local_ref}. Refusing to rebuild {}.",
2624                path.display()
2625            );
2626        }
2627        let worktree_head = self.head_oid_checked(&path)?;
2628        let recorded_head = self
2629            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2630            .trim()
2631            .to_string();
2632        if worktree_head != recorded_head {
2633            bail!(
2634                "the existing review worktree for PR #{number} has a local commit that is not on \
2635                 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2636                 {} before retrying.",
2637                path.display()
2638            );
2639        }
2640        self.report_recovery_markers(&path);
2641        self.refuse_dirty_worktree(
2642            &path,
2643            &format!("review worktree for PR #{number}"),
2644            Disposable::ReviewOutput,
2645        )?;
2646        Ok(())
2647    }
2648
2649    /// Read out what an earlier run refused to explain away, before rebuilding
2650    /// over it.
2651    ///
2652    /// The marker is the only record of why a call's answer was thrown out, and
2653    /// a review checkout is rebuilt on the next run. Saying it here is what
2654    /// keeps the file from being the thing that quietly blocks the command
2655    /// instead.
2656    fn report_recovery_markers(&self, path: &Path) {
2657        let Ok(entries) = std::fs::read_dir(path) else {
2658            return;
2659        };
2660        for entry in entries.flatten() {
2661            let name = entry.file_name();
2662            let Some(name) = name.to_str() else { continue };
2663            if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2664                continue;
2665            }
2666            match std::fs::read_to_string(entry.path()) {
2667                Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2668                Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2669            }
2670        }
2671    }
2672
2673    /// A worktree for one part of a split, on a new branch off `start`.
2674    ///
2675    /// `start` is the base branch for independent parts and the previous part's
2676    /// branch for stacked ones, which is the only difference between the two
2677    /// shapes at this level.
2678    ///
2679    /// The branch is whatever name was free, which is why it is returned rather
2680    /// than derived by the caller. Splitting the same pull request a second
2681    /// time would otherwise target the branch behind the first run's pull
2682    /// request. Split pushes are create-only and would refuse that target, but
2683    /// a repeated split still needs distinct branches rather than a name that
2684    /// can never be created.
2685    pub fn worktree_for_split(
2686        &self,
2687        parent: i64,
2688        index: usize,
2689        start: &str,
2690    ) -> Result<(PathBuf, String)> {
2691        let slot = self.free_split_slot(parent, index)?;
2692        let branch = format!("{}{slot}", self.branch_prefix);
2693        let path = self.worktree_path(&slot);
2694
2695        if let Some(dir) = path.parent() {
2696            std::fs::create_dir_all(dir)
2697                .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2698        }
2699        self.refuse_dirty_worktree(
2700            &path,
2701            &format!("worktree for part {index} of PR #{parent}"),
2702            Disposable::BuildOutput,
2703        )?;
2704        // The name is free, so there is no branch to delete. A directory can
2705        // still be in the way, left by a worktree that was pruned from git's
2706        // records without being removed from disk.
2707        if !self.remove_worktree_at(&path)? {
2708            bail!(
2709                "the existing worktree for part {index} of PR #{parent} could not be removed \
2710                 safely. No branch was created."
2711            );
2712        }
2713
2714        let path_str = path.display().to_string();
2715        self.git(&["worktree", "add", "-b", &branch, &path_str, start])
2716            .map_err(|e| {
2717                spar_err!(
2718                    "could not create a worktree for part {index} of #{parent}. {}",
2719                    e.last_line()
2720                )
2721            })?;
2722        // Recorded before anything else can fail. An unrecorded branch is one
2723        // `prune_branches` will never remove.
2724        self.record_branch(&branch, "split", parent);
2725        Ok((path, branch))
2726    }
2727
2728    /// The first part branch nothing is already sitting on.
2729    ///
2730    /// Origin as well as local, because a part's branch outlives the local one:
2731    /// a second split of the same pull request finds its own earlier branches
2732    /// deleted here but alive on origin, where the pull requests that reviewed
2733    /// them still point at them.
2734    fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2735        for attempt in 1..=SPLIT_SLOTS {
2736            let slot = split_slot(parent, index, attempt);
2737            let branch = format!("{}{slot}", self.branch_prefix);
2738            self.git_try(&["fetch", "origin", &branch]);
2739            if !self.rev_exists(&self.root, &branch)
2740                && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2741            {
2742                return Ok(slot);
2743            }
2744        }
2745        bail!(
2746            "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2747             names are all taken. Inspect the existing branches and child pull requests. Finish \
2748             recording the earlier split, or remove every retained local worktree and branch, \
2749             child pull request, and remote split branch before starting over.",
2750            self.branch_for_split(parent, index)
2751        )
2752    }
2753
2754    /// Whether a previous attempt pushed any branch for this split.
2755    ///
2756    /// The parent comment is the normal retry marker. A branch is the fallback
2757    /// when that comment or the pull request creation failed after the push.
2758    /// Reading origin directly makes the guard survive a fresh clone.
2759    pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2760        let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2761        Ok(!self
2762            .git(&["ls-remote", "--heads", "origin", &pattern])?
2763            .trim()
2764            .is_empty())
2765    }
2766
2767    /// Throw one part away: its worktree, its branch, and its record.
2768    ///
2769    /// For a part that would not stand on its own. Nothing has been pushed at
2770    /// that point, so this leaves no trace anywhere but the log. Takes what
2771    /// `worktree_for_split` returned, since the name it settled on is not
2772    /// derivable from the parent and the index.
2773    pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2774        match self.branch_deletion_is_safe(branch) {
2775            Ok(true) => {}
2776            Ok(false) => {
2777                logdim!(
2778                    "kept {branch} and {} because no surviving ref preserves its tip",
2779                    dir.display()
2780                );
2781                return;
2782            }
2783            Err(error) => {
2784                logdim!(
2785                    "kept {branch} and {} because preservation could not be verified: {}",
2786                    dir.display(),
2787                    error.last_line()
2788                );
2789                return;
2790            }
2791        }
2792        match self.remove_worktree_at(dir) {
2793            Ok(true) => match self.delete_branch_if_safe(branch) {
2794                Ok(true) => self.forget_branch(branch),
2795                Ok(false) => {
2796                    logdim!("kept {branch} because its tip or reflog changed before deletion")
2797                }
2798                Err(error) => logdim!(
2799                    "kept {branch} because deletion safety could not be rechecked: {}",
2800                    error.last_line()
2801                ),
2802            },
2803            Ok(false) => {}
2804            Err(error) => logdim!(
2805                "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
2806                dir.display(),
2807                error.last_line()
2808            ),
2809        }
2810    }
2811
2812    /// Discard one exact mechanical slice that the split workflow just made.
2813    ///
2814    /// Unlike ordinary release, this intentionally removes an unpushed commit.
2815    /// The caller supplies the exact disposable tip, and every file, worktree,
2816    /// ownership, and ref check must still match before anything is removed.
2817    pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
2818        let record = self.known_branches().get(branch).cloned();
2819        if record.is_none_or(|record| record.kind != "split") {
2820            logdim!("kept {branch} because no split branch record proves ownership");
2821            return false;
2822        }
2823        let local_ref = format!("refs/heads/{branch}");
2824        let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
2825            Ok(value) => value.trim().to_string(),
2826            Err(error) => {
2827                logdim!(
2828                    "kept {branch} because its tip could not be checked: {}",
2829                    error.last_line()
2830                );
2831                return false;
2832            }
2833        };
2834        if expected != disposable_head {
2835            logdim!("kept {branch} because it moved beyond the disposable slice");
2836            return false;
2837        }
2838        match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
2839            Ok(true) => {}
2840            Ok(false) => {
2841                logdim!(
2842                    "kept {branch} because its reflog contains work outside the disposable slice"
2843                );
2844                return false;
2845            }
2846            Err(error) => {
2847                logdim!(
2848                    "kept {branch} because its reflog could not be checked: {}",
2849                    error.last_line()
2850                );
2851                return false;
2852            }
2853        }
2854        match self.head_oid_checked(dir) {
2855            Ok(head) if head == disposable_head => {}
2856            Ok(_) => {
2857                logdim!(
2858                    "kept {branch} and {} because the worktree moved beyond the disposable slice",
2859                    dir.display()
2860                );
2861                return false;
2862            }
2863            Err(error) => {
2864                logdim!(
2865                    "kept {branch} and {} because its head could not be checked: {}",
2866                    dir.display(),
2867                    error.last_line()
2868                );
2869                return false;
2870            }
2871        }
2872        match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
2873            Ok(true) => {}
2874            Ok(false) => return false,
2875            Err(error) => {
2876                logdim!(
2877                    "kept {branch} and {} because the disposable slice could not be verified: {}",
2878                    dir.display(),
2879                    error.last_line()
2880                );
2881                return false;
2882            }
2883        }
2884        if let Err(error) =
2885            self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
2886        {
2887            logdim!(
2888                "kept {branch} because its exact disposable tip could not be deleted: {}",
2889                error.last_line()
2890            );
2891            return false;
2892        }
2893        match self.exact_ref_exists_checked(&self.root, &local_ref) {
2894            Ok(false) => {
2895                self.forget_branch(branch);
2896                true
2897            }
2898            Ok(true) => {
2899                logdim!("kept {branch} because its ref still exists after deletion");
2900                false
2901            }
2902            Err(error) => {
2903                logdim!(
2904                    "kept the branch record for {branch} because deletion could not be verified: {}",
2905                    error.last_line()
2906                );
2907                false
2908            }
2909        }
2910    }
2911
2912    pub fn release_review_worktree(&self, number: i64) {
2913        let path = self.worktree_path(&format!("review-{number}"));
2914        match self.review_ref_deletion_is_safe(number) {
2915            Ok(true) => {}
2916            Ok(false) => {
2917                logdim!(
2918                    "kept {} because no surviving ref preserves its review history",
2919                    path.display()
2920                );
2921                return;
2922            }
2923            Err(error) => {
2924                logdim!(
2925                    "kept {} because review history could not be verified: {}",
2926                    path.display(),
2927                    error.last_line()
2928                );
2929                return;
2930            }
2931        }
2932        match self.remove_worktree_at(&path) {
2933            Ok(true) => match self.delete_review_ref_if_safe(number) {
2934                Ok(true) => {}
2935                Ok(false) => logdim!(
2936                    "kept {} because its review history changed before deletion",
2937                    review_ref(number)
2938                ),
2939                Err(error) => logdim!(
2940                    "kept {} because deletion safety could not be rechecked: {}",
2941                    review_ref(number),
2942                    error.last_line()
2943                ),
2944            },
2945            Ok(false) => {}
2946            Err(error) => logdim!(
2947                "kept {} because removal did not reach a confirmed quiet point: {}",
2948                path.display(),
2949                error.last_line()
2950            ),
2951        }
2952    }
2953
2954    /// Release a read-only review checkout only when every observed part of
2955    /// its Git state still matches the checkpoint captured before the calls.
2956    pub(crate) fn release_review_worktree_checked(
2957        &self,
2958        number: i64,
2959        checkpoint: &WorktreeCheckpoint,
2960    ) -> Result<()> {
2961        let path = self.worktree_path(&format!("review-{number}"));
2962        self.require_unchanged_worktree(
2963            &path,
2964            checkpoint,
2965            &format!("review worktree for PR #{number}"),
2966        )?;
2967        if !self.review_ref_deletion_is_safe(number)? {
2968            bail!(
2969                "the review reference for PR #{number} has reflog-only recovery history. The \
2970                 worktree and reference were kept."
2971            );
2972        }
2973        if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
2974            bail!(
2975                "the verified review worktree at {} could not be removed, so its reference was \
2976                 kept",
2977                path.display()
2978            );
2979        }
2980        if !self.delete_review_ref_if_safe(number)? {
2981            bail!(
2982                "the review reference for PR #{number} changed before deletion. The reference was \
2983                 kept."
2984            );
2985        }
2986        Ok(())
2987    }
2988
2989    pub fn release_pr_worktree(&self, number: i64) -> bool {
2990        let path = self.worktree_path(&format!("pr-{number}"));
2991        let local = self.branch_for_pr(number);
2992        match self.branch_deletion_is_safe(&local) {
2993            Ok(true) => {}
2994            Ok(false) => {
2995                logdim!(
2996                    "kept {local} and {} because no surviving ref preserves its tip",
2997                    path.display()
2998                );
2999                return false;
3000            }
3001            Err(error) => {
3002                logdim!(
3003                    "kept {local} and {} because preservation could not be verified: {}",
3004                    path.display(),
3005                    error.last_line()
3006                );
3007                return false;
3008            }
3009        }
3010        match self.remove_worktree_at(&path) {
3011            Ok(true) => match self.delete_branch_if_safe(&local) {
3012                Ok(true) => {
3013                    self.forget_branch(&local);
3014                    true
3015                }
3016                Ok(false) => {
3017                    logdim!("kept {local} because its tip or reflog changed before deletion");
3018                    false
3019                }
3020                Err(error) => {
3021                    logdim!(
3022                        "kept {local} because deletion safety could not be rechecked: {}",
3023                        error.last_line()
3024                    );
3025                    false
3026                }
3027            },
3028            Ok(false) => false,
3029            Err(error) => {
3030                logdim!(
3031                    "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
3032                    path.display(),
3033                    error.last_line()
3034                );
3035                false
3036            }
3037        }
3038    }
3039
3040    // -- branch state -----------------------------------------------------
3041
3042    /// What to diff against: the remote tracking branch when it resolves, the
3043    /// local branch when it does not.
3044    ///
3045    /// This is not a nicety. Every "did the agent do anything" check hangs off
3046    /// this ref, and `git log` against a ref that does not exist fails silently
3047    /// and reads as "no commits". A checkout whose `origin/main` was never
3048    /// fetched would report every implementation as abandoned and throw the
3049    /// work away.
3050    pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
3051        let remote = format!("origin/{base}");
3052        if self.rev_exists(cwd, &remote) {
3053            return remote;
3054        }
3055        if self.rev_exists(cwd, base) {
3056            logdim!("origin/{base} does not resolve, comparing against local {base}");
3057            return base.to_string();
3058        }
3059        logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
3060        remote
3061    }
3062
3063    fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
3064        let spec = format!("{refname}^{{commit}}");
3065        !self
3066            .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
3067            .trim()
3068            .is_empty()
3069    }
3070
3071    pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
3072        let range = format!("{}..HEAD", self.base_ref(cwd, base));
3073        !self
3074            .git_try_at(Some(cwd), &["log", &range, "--oneline"])
3075            .trim()
3076            .is_empty()
3077    }
3078
3079    fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
3080        let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
3081        Ok(found.lines().any(|line| line.trim() == refname))
3082    }
3083
3084    fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
3085        let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
3086        count.trim().parse::<usize>().map_err(|e| {
3087            spar_err!(
3088                "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
3089                count.trim()
3090            )
3091        })
3092    }
3093
3094    pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
3095        let remote = format!("refs/remotes/origin/{base}");
3096        if self.exact_ref_exists_checked(cwd, &remote)? {
3097            return Ok(remote);
3098        }
3099        let local = format!("refs/heads/{base}");
3100        if self.exact_ref_exists_checked(cwd, &local)? {
3101            return Ok(local);
3102        }
3103        bail!("neither origin/{base} nor local branch {base} resolves")
3104    }
3105
3106    pub(crate) fn commit_count_checked(
3107        &self,
3108        cwd: &Path,
3109        refname: &str,
3110        base: &str,
3111    ) -> Result<usize> {
3112        let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
3113        let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
3114        count.trim().parse::<usize>().map_err(|e| {
3115            spar_err!(
3116                "git returned an invalid commit count for {range}: {:?} ({e})",
3117                count.trim()
3118            )
3119        })
3120    }
3121
3122    pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
3123        Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
3124    }
3125
3126    pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
3127        let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
3128        let head = head.trim().to_string();
3129        if head.is_empty() {
3130            bail!("git returned an empty HEAD for {}", cwd.display());
3131        }
3132        Ok(head)
3133    }
3134
3135    /// Whether a recorded SPAR branch's exact tip is retained by a pull request.
3136    ///
3137    /// Pull request head refs remain available after close or merge, so this is
3138    /// stronger than requiring an open pull request or a live remote branch.
3139    pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
3140        let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
3141        self.local_branch_is_preserved(branch.trim())
3142    }
3143
3144    /// Whether a recorded local branch's exact tip is retained by its pull
3145    /// request head, regardless of which branch is currently checked out.
3146    pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
3147        let known = self.known_branches();
3148        let Some(record) = known.get(branch) else {
3149            return Ok(false);
3150        };
3151        self.branch_is_preserved_checked(branch, record)
3152    }
3153
3154    /// Whether tracked, staged, or non-ignored untracked files are uncommitted.
3155    pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
3156        has_uncommitted_work(cwd)
3157    }
3158
3159    /// Whether removing a worktree would delete any local file Git does not
3160    /// reproduce from its commits, including ignored untracked files.
3161    fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
3162        repository_has_recoverable_work_with(cwd, true, disposable)
3163    }
3164
3165    /// Record ignored artifacts that existed before an editing call.
3166    pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
3167        let attributes = attribute_state(cwd)?;
3168        Ok(WorktreeBaseline {
3169            attributes,
3170            ignored_untracked: ignored_untracked_state(cwd)?,
3171            git_state: safe_git_state(cwd)?,
3172        })
3173    }
3174
3175    /// Capture the Git state of a checkout intended to remain read only while
3176    /// external commands inspect it.
3177    pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
3178        let attributes = attribute_state(cwd)?;
3179        Ok(WorktreeCheckpoint {
3180            path: std::fs::canonicalize(cwd)
3181                .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
3182            attributes,
3183            git_state: safe_git_state(cwd)?,
3184            ignored_untracked: ignored_untracked_state(cwd)?,
3185        })
3186    }
3187
3188    /// Require a read-only checkout to match a previously captured checkpoint.
3189    /// Any probe failure is an error because deletion cannot be proven safe.
3190    pub(crate) fn require_unchanged_worktree(
3191        &self,
3192        cwd: &Path,
3193        checkpoint: &WorktreeCheckpoint,
3194        label: &str,
3195    ) -> Result<()> {
3196        let resolved = std::fs::canonicalize(cwd).map_err(|e| {
3197            crate::error::SparError::uncertain_write(format!(
3198                "could not resolve the {label} at {} after inspection: {e}. It was kept.",
3199                cwd.display()
3200            ))
3201        })?;
3202        if resolved != checkpoint.path {
3203            return Err(uncertain_worktree_change(
3204                cwd,
3205                format!(
3206                    "the {label} moved from {} to {} during inspection. It was kept.",
3207                    checkpoint.path.display(),
3208                    resolved.display()
3209                ),
3210            ));
3211        }
3212        let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
3213            uncertain_worktree_change(
3214                cwd,
3215                format!(
3216                    "could not verify attribute files in the {label} at {}: {}. It was kept.",
3217                    cwd.display(),
3218                    e.last_line()
3219                ),
3220            )
3221        })?;
3222        if attributes != checkpoint.attributes {
3223            return Err(uncertain_worktree_change(
3224                cwd,
3225                format!(
3226                    "attribute files in the {label} at {} changed during inspection. It was \
3227                     kept for recovery.",
3228                    cwd.display()
3229                ),
3230            ));
3231        }
3232        let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
3233            uncertain_worktree_change(
3234                cwd,
3235                format!(
3236                    "could not verify the Git state of the {label} at {}: {}. It was kept.",
3237                    cwd.display(),
3238                    e.last_line()
3239                ),
3240            )
3241        })?;
3242        let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
3243            uncertain_worktree_change(
3244                cwd,
3245                format!(
3246                    "could not verify untracked files in the {label} at {}: {}. It was kept.",
3247                    cwd.display(),
3248                    e.last_line()
3249                ),
3250            )
3251        })?;
3252        let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
3253        // Said even when the inspection is accepted, for the same reason it is
3254        // said on an agent call: a path the project ignores is out of scope,
3255        // not unnoticed.
3256        note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
3257        let tracked = checkpoint.git_state.describe_difference(&git_state);
3258        let untracked = (!leavings.condemning.is_empty()).then(|| {
3259            format!(
3260                "untracked file(s) changed: {}",
3261                list_paths(&leavings.condemning)
3262            )
3263        });
3264        let Some(reason) = join_reasons(tracked, untracked) else {
3265            return Ok(());
3266        };
3267        Err(uncertain_worktree_change(
3268            cwd,
3269            format!(
3270                "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
3271                 for recovery.",
3272                cwd.display()
3273            ),
3274        ))
3275    }
3276
3277    /// Accept project-ignored files left by a successful editing call.
3278    /// Ordinary untracked files must already be represented by the commit.
3279    /// Acceptance does not authorize cleanup of the ignored files.
3280    pub(crate) fn refuse_new_ignored_files(
3281        &self,
3282        cwd: &Path,
3283        baseline: &WorktreeBaseline,
3284    ) -> Result<()> {
3285        self.allow_ignored_files(cwd, baseline).map(drop)
3286    }
3287
3288    /// Preserve unexpected output when the editing call did not finish.
3289    pub(crate) fn refuse_failed_edit_leavings(
3290        &self,
3291        cwd: &Path,
3292        baseline: &WorktreeBaseline,
3293    ) -> Result<()> {
3294        self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
3295            .map(drop)
3296    }
3297
3298    /// Reject ordinary untracked changes after a read-only call.
3299    pub(crate) fn refuse_read_only_leavings(
3300        &self,
3301        cwd: &Path,
3302        baseline: &WorktreeBaseline,
3303    ) -> Result<()> {
3304        self.refuse_changed_attributes(cwd, baseline)?;
3305        let after = ignored_untracked_state(cwd).map_err(|e| {
3306            uncertain_worktree_change(
3307                cwd,
3308                format!(
3309                    "could not verify untracked files in {} after a read-only call: {}. The \
3310                     worktree was kept for recovery.",
3311                    cwd.display(),
3312                    e.last_line()
3313                ),
3314            )
3315        })?;
3316        let leavings = baseline.ignored_untracked.read_only_leavings(&after);
3317        if leavings.condemning.is_empty() {
3318            return Ok(());
3319        }
3320        Err(uncertain_worktree_change(
3321            cwd,
3322            format!(
3323                "the read-only call left untracked file(s) in {} that the project does not \
3324                 ignore: {}. The worktree was kept for recovery.",
3325                cwd.display(),
3326                list_paths(&leavings.condemning)
3327            ),
3328        ))
3329    }
3330
3331    /// The ignored paths the check let through, for one report per attempt
3332    /// rather than one per check.
3333    fn allow_ignored_files(
3334        &self,
3335        cwd: &Path,
3336        baseline: &WorktreeBaseline,
3337    ) -> Result<AllowedArtifacts> {
3338        self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
3339    }
3340
3341    fn check_new_ignored_files(
3342        &self,
3343        cwd: &Path,
3344        baseline: &WorktreeBaseline,
3345        allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
3346    ) -> Result<AllowedArtifacts> {
3347        self.refuse_changed_attributes(cwd, baseline)?;
3348        let after = ignored_untracked_state(cwd).map_err(|e| {
3349            uncertain_worktree_change(
3350                cwd,
3351                format!(
3352                    "could not verify untracked files in {} after editing: {}. The worktree was \
3353                     kept for recovery.",
3354                    cwd.display(),
3355                    e.last_line()
3356                ),
3357            )
3358        })?;
3359        let changed = baseline.ignored_untracked.changed_paths(&after);
3360        if changed.is_empty() {
3361            return Ok(AllowedArtifacts::default());
3362        }
3363        let (ignored, changed): (Vec<_>, Vec<_>) = changed
3364            .into_iter()
3365            .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
3366        if changed.is_empty() {
3367            return Ok(AllowedArtifacts::split(ignored, &after));
3368        }
3369        let listed = list_paths(&changed);
3370        Err(uncertain_worktree_change(
3371            cwd,
3372            format!(
3373                "the editing call created or changed untracked or ignored file(s) in {} that \
3374                 cannot be represented by a managed commit: {listed}. The worktree was kept for \
3375                 recovery.",
3376                cwd.display()
3377            ),
3378        ))
3379    }
3380
3381    /// Existing untracked files belong to the checkout owner, even when a call
3382    /// also produces a valid tracked change. Refuse their modification or
3383    /// deletion before accepting the tracked result. Project-ignored files
3384    /// stay outside the commit, wherever the project puts them.
3385    pub(crate) fn refuse_changed_existing_untracked(
3386        &self,
3387        cwd: &Path,
3388        baseline: &WorktreeBaseline,
3389    ) -> Result<()> {
3390        self.check_changed_existing_untracked(cwd, baseline)
3391            .map(drop)
3392    }
3393
3394    fn allow_changed_ignored_artifacts(
3395        &self,
3396        cwd: &Path,
3397        baseline: &WorktreeBaseline,
3398    ) -> Result<AllowedArtifacts> {
3399        self.check_changed_existing_untracked(cwd, baseline)
3400    }
3401
3402    fn check_changed_existing_untracked(
3403        &self,
3404        cwd: &Path,
3405        baseline: &WorktreeBaseline,
3406    ) -> Result<AllowedArtifacts> {
3407        self.refuse_changed_attributes(cwd, baseline)?;
3408        let after = ignored_untracked_state(cwd).map_err(|e| {
3409            uncertain_worktree_change(
3410                cwd,
3411                format!(
3412                    "could not verify existing untracked files in {} after editing: {}. The \
3413                     worktree was kept for recovery.",
3414                    cwd.display(),
3415                    e.last_line()
3416                ),
3417            )
3418        })?;
3419        let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3420        if changed.is_empty() {
3421            return Ok(AllowedArtifacts::default());
3422        }
3423        let (ignored, changed): (Vec<_>, Vec<_>) = changed
3424            .into_iter()
3425            .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3426        if changed.is_empty() {
3427            return Ok(AllowedArtifacts::split(ignored, &after));
3428        }
3429        let listed = list_paths(&changed);
3430        Err(uncertain_worktree_change(
3431            cwd,
3432            format!(
3433                "the editing call changed or deleted existing untracked file(s) in {}: \
3434                 {listed}. The worktree was kept for recovery.",
3435                cwd.display()
3436            ),
3437        ))
3438    }
3439
3440    /// Refuse a byte or mode change that the index did not represent.
3441    ///
3442    /// Clean filters can normalize a working file back to its existing blob,
3443    /// and index flags can hide a change from porcelain status. Comparing the
3444    /// actual tracked files on both sides keeps those bytes from being treated
3445    /// as disposable just because Git has no diff for them.
3446    pub(crate) fn refuse_unrepresented_tracked_changes(
3447        &self,
3448        cwd: &Path,
3449        baseline: &WorktreeBaseline,
3450    ) -> Result<()> {
3451        self.refuse_changed_attributes(cwd, baseline)?;
3452        let after = safe_git_state(cwd).map_err(|e| {
3453            uncertain_worktree_change(
3454                cwd,
3455                format!(
3456                    "could not verify tracked files in {} after editing: {}. The worktree was \
3457                     kept for recovery.",
3458                    cwd.display(),
3459                    e.last_line()
3460                ),
3461            )
3462        })?;
3463        let mut changed = Vec::new();
3464        let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3465            uncertain_worktree_change(
3466                cwd,
3467                format!(
3468                    "could not record untracked files before verifying transformed content in {}: \
3469                     {}. The worktree was kept for recovery.",
3470                    cwd.display(),
3471                    e.last_line()
3472                ),
3473            )
3474        })?;
3475        let mut filter_was_run = false;
3476        let mut filter_problem = None;
3477        let mut repositories: BTreeSet<PathBuf> =
3478            baseline.git_state.repositories.keys().cloned().collect();
3479        repositories.extend(after.repositories.keys().cloned());
3480        'repositories: for repository_path in repositories {
3481            let before_repository = baseline.git_state.repositories.get(&repository_path);
3482            let after_repository = after.repositories.get(&repository_path);
3483            if before_repository.is_none() || after_repository.is_none() {
3484                changed.push(repository_path.clone());
3485                continue;
3486            }
3487            if before_repository.map(|repository| &repository.gitlinks)
3488                != after_repository.map(|repository| &repository.gitlinks)
3489            {
3490                changed.push(repository_path.join("<gitlinks>"));
3491            }
3492            let mut paths = BTreeSet::new();
3493            if let Some(repository) = before_repository {
3494                paths.extend(repository.tracked.keys().cloned());
3495            }
3496            if let Some(repository) = after_repository {
3497                paths.extend(repository.tracked.keys().cloned());
3498            }
3499            for path in paths {
3500                let before = before_repository.and_then(|repository| repository.tracked.get(&path));
3501                let current = after_repository.and_then(|repository| repository.tracked.get(&path));
3502                let worktree_changed =
3503                    before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
3504                let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
3505                    != current.map(|entry| (&entry.index_mode, &entry.index_oid));
3506                if !worktree_changed {
3507                    continue;
3508                }
3509                if !index_changed {
3510                    changed.push(repository_path.join(&path));
3511                    continue;
3512                }
3513                let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
3514                let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
3515                let Some(current_entry) = current else {
3516                    continue;
3517                };
3518                let Some(current_worktree) = current_worktree else {
3519                    continue;
3520                };
3521                let content_changed =
3522                    before_worktree.map(|file| file.content) != Some(current_worktree.content);
3523                let mode_changed = before_worktree.map(|file| file.mode.as_str())
3524                    != Some(current_worktree.mode.as_str());
3525                let repository = cwd.join(&repository_path);
3526                let represented_content = if content_changed {
3527                    filter_was_run = true;
3528                    let result =
3529                        filtered_index_content(&repository, &path, &current_entry.index_oid);
3530                    self.refuse_changed_attributes(cwd, baseline)?;
3531                    match result {
3532                        Ok(expected) => expected == current_worktree.content,
3533                        Err(error) => {
3534                            filter_problem = Some(format!(
3535                                "could not verify transformed content for {:?}: {}",
3536                                repository_path.join(&path),
3537                                error.last_line()
3538                            ));
3539                            false
3540                        }
3541                    }
3542                } else {
3543                    true
3544                };
3545                let represented_mode =
3546                    !mode_changed || current_worktree.mode == current_entry.index_mode;
3547                if !represented_content || !represented_mode {
3548                    changed.push(repository_path.join(&path));
3549                }
3550                if filter_problem.is_some() {
3551                    break 'repositories;
3552                }
3553            }
3554        }
3555        if filter_was_run {
3556            self.refuse_changed_attributes(cwd, baseline)?;
3557            let verified = safe_git_state(cwd).map_err(|e| {
3558                uncertain_worktree_change(
3559                    cwd,
3560                    format!(
3561                        "could not recheck tracked files after verifying transformed content in \
3562                         {}: {}. The worktree was kept for recovery.",
3563                        cwd.display(),
3564                        e.last_line()
3565                    ),
3566                )
3567            })?;
3568            let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3569                uncertain_worktree_change(
3570                    cwd,
3571                    format!(
3572                        "could not recheck untracked files after verifying transformed content \
3573                         in {}: {}. The worktree was kept for recovery.",
3574                        cwd.display(),
3575                        e.last_line()
3576                    ),
3577                )
3578            })?;
3579            if verified != after
3580                || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3581            {
3582                return Err(uncertain_worktree_change(
3583                    cwd,
3584                    "a content filter changed the worktree while SPAR verified the managed \
3585                     commit. The worktree was kept for recovery.",
3586                ));
3587            }
3588            self.refuse_changed_existing_untracked(cwd, baseline)?;
3589        }
3590        if let Some(problem) = filter_problem {
3591            return Err(uncertain_worktree_change(
3592                cwd,
3593                format!("{problem}. The worktree was kept for recovery."),
3594            ));
3595        }
3596        if changed.is_empty() {
3597            return Ok(());
3598        }
3599        let listed = list_paths(&changed);
3600        Err(uncertain_worktree_change(
3601            cwd,
3602            format!(
3603                "the editing call changed tracked working-file bytes, modes, repositories, or \
3604                 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3605                 recovery."
3606            ),
3607        ))
3608    }
3609
3610    pub(crate) fn refuse_changed_attributes(
3611        &self,
3612        cwd: &Path,
3613        baseline: &WorktreeBaseline,
3614    ) -> Result<()> {
3615        let after = attribute_state(cwd).map_err(|e| {
3616            uncertain_worktree_change(
3617                cwd,
3618                format!(
3619                    "could not verify attribute files in {} after editing: {}. The worktree was \
3620                     kept for recovery.",
3621                    cwd.display(),
3622                    e.last_line()
3623                ),
3624            )
3625        })?;
3626        if after == baseline.attributes {
3627            return Ok(());
3628        }
3629        Err(uncertain_worktree_change(
3630            cwd,
3631            format!(
3632                "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
3633                 refused to run a Git operation that could select a new external filter.",
3634                cwd.display()
3635            ),
3636        ))
3637    }
3638
3639    /// Commit a successful editing call from the trusted harness process.
3640    ///
3641    /// Editing sandboxes only need the working tree. They never need writable
3642    /// access to the repository's object database, refs, config, or hooks.
3643    pub(crate) fn commit_pending_changes(
3644        &self,
3645        cwd: &Path,
3646        baseline: &WorktreeBaseline,
3647        preferred_subject: &str,
3648        fallback_subject: &str,
3649    ) -> Result<bool> {
3650        let mut artifacts = IgnoredArtifacts::default();
3651        self.refuse_changed_attributes(cwd, baseline)?;
3652        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3653        refuse_unsafe_index_flags(cwd)?;
3654        if !self.has_uncommitted_changes(cwd)? {
3655            artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3656            artifacts.report(cwd);
3657            return Ok(false);
3658        }
3659        self.stage_managed_changes(cwd, baseline).map_err(|e| {
3660            e.with_message(format!(
3661                "could not stage changes in {}: {}",
3662                cwd.display(),
3663                e.last_line()
3664            ))
3665        })?;
3666        // Git ignore rules decide which files stay outside the commit.
3667        // Whether cleanup can delete them is a separate decision.
3668        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3669        let changed_gitlinks = changed_staged_gitlinks(cwd)?;
3670        if !changed_gitlinks.is_empty() {
3671            let listed = list_paths(&changed_gitlinks);
3672            bail!(
3673                "the editing call added or changed a gitlink at {listed}. It was staged but not \
3674                 committed because the referenced repository objects might exist only inside \
3675                 this worktree. The worktree was kept for recovery."
3676            );
3677        }
3678        let mut subject = self.clean_title(preferred_subject)?;
3679        if subject.trim().is_empty() {
3680            subject = self.clean_title(fallback_subject)?;
3681        }
3682        self.commit_staged_changes(cwd, &subject).map_err(|e| {
3683            e.with_message(format!(
3684                "could not commit changes in {}: {}. The staged files were kept.",
3685                cwd.display(),
3686                e.last_line()
3687            ))
3688        })?;
3689        if has_tracked_or_staged_work(cwd)? {
3690            bail!(
3691                "the commit in {} left additional uncommitted files. They were kept for \
3692                 recovery.",
3693                cwd.display()
3694            );
3695        }
3696        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3697        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3698        artifacts.report(cwd);
3699        Ok(true)
3700    }
3701
3702    fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
3703        let after = ignored_untracked_state(cwd)?;
3704        self.git_at_without_automation(cwd, &["add", "-u"])?;
3705        let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
3706        if paths.is_empty() {
3707            return Ok(());
3708        }
3709        let mut input = Vec::new();
3710        for path in paths {
3711            input.extend(os_str_bytes(path.as_os_str())?);
3712            input.push(0);
3713        }
3714        let argv = git_without_automation_argv(&[
3715            "--literal-pathspecs",
3716            "add",
3717            "--pathspec-from-file=-",
3718            "--pathspec-file-nul",
3719        ]);
3720        proc::run_with_input_bytes(
3721            &argv,
3722            &self.git_opts(Some(cwd), true).stop_descendants(true),
3723            &input,
3724        )?;
3725        Ok(())
3726    }
3727
3728    /// Commit an index prepared by the parent without signing, hooks, or
3729    /// inherited repository automation.
3730    pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
3731        self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
3732            .map(|_| ())
3733    }
3734
3735    /// How many commits `refname` carries that the base does not.
3736    ///
3737    /// Counted from the commits themselves rather than from `commit_subjects`,
3738    /// which drops a commit whose message is empty. The guards in
3739    /// `worktree_add` decide whether to delete a branch on this number, and an
3740    /// empty message must not read as an empty branch.
3741    pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
3742        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3743        self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
3744            .trim()
3745            .parse()
3746            .unwrap_or(0)
3747    }
3748
3749    /// One `hash subject` line per commit `refname` carries that the base does
3750    /// not, oldest first. For showing a person what is on a branch, so the
3751    /// hash keeps a commit with no message from listing as nothing.
3752    pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3753        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3754        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3755            .lines()
3756            .map(str::to_string)
3757            .collect()
3758    }
3759
3760    /// The commits `later` carries that `earlier` does not, oldest first, when
3761    /// `earlier` is genuinely behind it.
3762    ///
3763    /// `None` when it is not an ancestor, which is not the same as nothing
3764    /// having landed. `rewrite_commits_if_needed` rewrites hashes from the first
3765    /// offending commit spar made onward, so a head recorded part way through a
3766    /// round can still be a readable object and no longer be on the branch. `git log` answers that
3767    /// with every commit on the branch, so without the check the one caller
3768    /// would report the whole branch as unread, which is the widest possible
3769    /// wrong answer.
3770    ///
3771    /// No `base_ref` resolution, unlike its neighbours: these are commits rather
3772    /// than branch names, and putting a sha through it logs a fallback line
3773    /// every time.
3774    pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3775        let ancestor = self
3776            .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3777            .is_ok();
3778        if !ancestor {
3779            return None;
3780        }
3781        let range = format!("{earlier}..{later}");
3782        Some(
3783            self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3784                .lines()
3785                .map(str::to_string)
3786                .collect(),
3787        )
3788    }
3789
3790    /// The subjects of the commits `refname` carries that the base does not,
3791    /// oldest first.
3792    pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3793        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3794        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3795            .lines()
3796            .map(str::trim)
3797            .filter(|line| !line.is_empty())
3798            .map(str::to_string)
3799            .collect()
3800    }
3801
3802    /// The paths this checkout changes relative to the base, sorted.
3803    ///
3804    /// A three dot range, matching `diff_stat`: what the branch did, not what
3805    /// the base has done since.
3806    ///
3807    /// `--no-renames` because a rename reported as its destination alone leaves
3808    /// the source out of the list, and a part carrying only the destination
3809    /// would be a copy. As a deletion and an addition it is two paths, which a
3810    /// part can carry together or leave to the leftover report.
3811    ///
3812    /// `-z` because without it git writes paths for display: anything
3813    /// non-ASCII comes back escaped and wrapped in quotes, and that string is
3814    /// not a path. A part built from one carries a pathspec matching no file,
3815    /// so the file never reaches the slice while every list still says the part
3816    /// took it. It also keeps a path with a space at either end intact.
3817    pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
3818        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3819        self.git_try_at(
3820            Some(cwd),
3821            &["diff", "--name-only", "--no-renames", "-z", &range],
3822        )
3823        .split('\0')
3824        .filter(|path| !path.is_empty())
3825        .map(str::to_string)
3826        .collect()
3827    }
3828
3829    /// The lines a line comment can go on: the new side of each hunk of the
3830    /// branch's three dot diff, which is the diff GitHub shows for a pull
3831    /// request.
3832    ///
3833    /// Every flag that shapes the hunks is spelled out as git's own default,
3834    /// which is what GitHub shows, so a user's `diff.noprefix`, `diff.context`,
3835    /// `diff.interHunkContext`, `diff.algorithm` or `diff.submodule` cannot
3836    /// move a line comment onto a line GitHub does not show. `GIT_DIFF_OPTS`
3837    /// is emptied for the same reason: git reads it after the command line,
3838    /// so it would widen every hunk past `--unified=3`. `--no-ext-diff` and
3839    /// `--no-textconv` also matter for more than the shape: the branch being
3840    /// reviewed is somebody else's, and a diff driver or text converter would
3841    /// run a script it changed. A failure is no hunks, which only means every
3842    /// finding stays in the summary.
3843    pub fn diff_hunks(&self, cwd: &Path, base: &str) -> Hunks {
3844        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3845        let argv = git_without_maintenance_argv(&[
3846            "-c",
3847            "core.quotePath=false",
3848            "diff",
3849            "--no-color",
3850            "--no-ext-diff",
3851            "--no-textconv",
3852            "--unified=3",
3853            "--inter-hunk-context=0",
3854            "--diff-algorithm=myers",
3855            "--indent-heuristic",
3856            "--submodule=short",
3857            "--find-renames",
3858            "--src-prefix=a/",
3859            "--dst-prefix=b/",
3860            &range,
3861        ]);
3862        let opts = self.git_opts(Some(cwd), false).env("GIT_DIFF_OPTS", "");
3863        new_side_hunks(&proc::run(&argv, &opts).unwrap_or_default())
3864    }
3865
3866    /// The files one commit range touched, for checking a claim against the
3867    /// diff rather than against the report that made it.
3868    pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
3869        self.git_try_at(
3870            Some(cwd),
3871            &[
3872                "diff",
3873                "--name-only",
3874                "--no-renames",
3875                "-z",
3876                &format!("{from}..{to}"),
3877            ],
3878        )
3879        .split('\0')
3880        .filter(|path| !path.is_empty())
3881        .map(str::to_string)
3882        .collect()
3883    }
3884
3885    /// Where `refname` left the base: the commit its own change is measured
3886    /// from, and the one a slice of that change has to be taken against.
3887    pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
3888        let base_ref = self.base_ref(cwd, base);
3889        let out = self
3890            .git_at(Some(cwd), &["merge-base", &base_ref, refname])
3891            .map_err(|e| {
3892                spar_err!(
3893                    "could not find where {refname} and {base_ref} diverged. {}",
3894                    e.last_line()
3895                )
3896            })?;
3897        let sha = out.trim().to_string();
3898        if sha.is_empty() {
3899            bail!("{refname} and {base_ref} share no history");
3900        }
3901        Ok(sha)
3902    }
3903
3904    pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
3905        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3906        let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
3907        full.trim().to_string()
3908    }
3909
3910    /// Scrub commit messages that slipped past the prompt.
3911    ///
3912    /// `git filter-branch` calls back into this same binary, so there is no
3913    /// interpreter to find and no second copy of the rules to drift.
3914    ///
3915    /// `floor` is the head spar found when it started, and nothing at or below
3916    /// it is rewritten. The style gate is for what spar writes, and a person's
3917    /// commit message is not that: rewriting one changes a published SHA, which
3918    /// diverges the branch under somebody's own checkout and is exactly the
3919    /// history rewrite this design promises never to do. A `None` floor means
3920    /// every commit above the base is spar's own, which is true of a branch
3921    /// spar created in this invocation and of nothing else.
3922    pub fn rewrite_commits_if_needed(
3923        &self,
3924        cwd: &Path,
3925        base: &str,
3926        floor: Option<&str>,
3927    ) -> Result<()> {
3928        let base_ref = self.base_ref(cwd, base);
3929        let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
3930        self.report_commits_left_alone(cwd, &base_ref, floor);
3931
3932        let range = format!("{floor}..HEAD");
3933        let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
3934
3935        let offenders = raw
3936            .split('\x1e')
3937            .filter_map(|entry| entry.split_once('\0'))
3938            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3939            .count();
3940        if offenders == 0 {
3941            return Ok(());
3942        }
3943        logdim!("{offenders} commit message(s) violated style rules, rewriting");
3944
3945        let exe = self_binary()?;
3946        let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
3947
3948        let argv: Vec<String> = [
3949            "git",
3950            "filter-branch",
3951            "-f",
3952            "--msg-filter",
3953            &filter,
3954            &range,
3955        ]
3956        .iter()
3957        .map(|s| s.to_string())
3958        .collect();
3959        let opts = ExecOpts::new()
3960            .cwd(cwd)
3961            .check(false)
3962            .timeout_secs(600)
3963            .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
3964            .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
3965            .env(
3966                "SPAR_BAN_AI_ATTRIBUTION",
3967                bool_env(self.style.ban_ai_attribution),
3968            );
3969        let _ = proc::run(&argv, &opts);
3970
3971        let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
3972        if !style::violations(&after, &self.style).is_empty() {
3973            bail!(
3974                "commit messages still violate style rules after a rewrite in {}.",
3975                cwd.display()
3976            );
3977        }
3978        Ok(())
3979    }
3980
3981    /// Say out loud when a message spar will not touch breaks the style rules.
3982    ///
3983    /// Silence here would read as "there was nothing to scrub", and the point
3984    /// of the floor is that there was something and spar chose to leave it.
3985    fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
3986        if floor == base_ref {
3987            return;
3988        }
3989        let raw = self.git_try_at(
3990            Some(cwd),
3991            &[
3992                "log",
3993                &format!("{base_ref}..{floor}"),
3994                "--format=%H%x00%B%x1e",
3995            ],
3996        );
3997        let left: Vec<&str> = raw
3998            .split('\x1e')
3999            .filter_map(|entry| entry.split_once('\0'))
4000            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4001            .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
4002            .collect();
4003        if left.is_empty() {
4004            return;
4005        }
4006        logdim!(
4007            "{} commit message(s) already on the branch break the style rules and were left \
4008             alone: {}. spar does not rewrite commits it did not write.",
4009            left.len(),
4010            left.join(", ")
4011        );
4012    }
4013
4014    /// Push by explicit refspec from HEAD, leasing the head spar reviewed.
4015    ///
4016    /// A resumed PR is checked out under a local name (`pr-N`) that does not
4017    /// match its remote branch, so pushing by branch name would resolve the
4018    /// wrong local ref or fail outright.
4019    ///
4020    /// `expected` is the remote head spar last read. A bare `--force-with-lease`
4021    /// leases the implicit tracking ref instead, which is shared across every
4022    /// worktree and is updated by any `git fetch`. The editing agent is a real
4023    /// CLI with shell access, and fetching is a normal thing for it to do: one
4024    /// `git fetch` mid round moves the tracking ref to whatever the remote holds
4025    /// and the lease then passes against a head spar never reviewed, which
4026    /// silently drops somebody's commit. Naming the head makes the push refuse
4027    /// exactly that case.
4028    pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
4029        self.push_to(cwd, &PushTarget::Origin, branch, expected)
4030    }
4031
4032    /// `push`, to wherever the branch was first published.
4033    pub fn push_to(
4034        &self,
4035        cwd: &Path,
4036        target: &PushTarget,
4037        branch: &str,
4038        expected: Option<&str>,
4039    ) -> Result<()> {
4040        let expected = expected.map(str::trim).filter(|head| !head.is_empty());
4041        if let Some(head) = expected {
4042            self.refuse_push_that_drops(cwd, head)?;
4043        }
4044        let pushed = self
4045            .git_push(cwd, target, branch, expected)
4046            .map_err(|e| push_error(target, branch, &e));
4047        self.record_write(pushed)
4048    }
4049
4050    /// Publish a branch created in this invocation, from a fork if origin
4051    /// refuses it.
4052    ///
4053    /// Only a new branch can move. A pull request that already exists has its
4054    /// branch where it is, and pushing it anywhere else would not update it. A
4055    /// new one has no pull request yet, so a fork is just where it is opened
4056    /// from, which is how anybody without write access contributes.
4057    ///
4058    /// One write whichever way it goes. The refusal is not counted as a
4059    /// failure when the fork takes the branch: the run did what it set out to,
4060    /// and a failed write makes the exit status say otherwise.
4061    pub fn publish_new_branch(&self, cwd: &Path, branch: &str, base: &str) -> Result<PushTarget> {
4062        let origin = PushTarget::Origin;
4063        let refused = match self.git_push(cwd, &origin, branch, None) {
4064            Ok(()) => return self.record_write(Ok(origin)),
4065            Err(e) if self.fork_when_denied && push_denial(&e.to_string()).is_some() => e,
4066            Err(e) => {
4067                let err = push_error(&origin, branch, &e);
4068                let err = if push_denial(&e.to_string()).is_some() {
4069                    spar_err!(
4070                        "{err} Set fork_when_denied = true under [loop] to publish it from a fork \
4071                         instead."
4072                    )
4073                } else {
4074                    err
4075                };
4076                return self.record_write(Err(err));
4077            }
4078        };
4079        self.record_write(self.publish_on_fork(cwd, branch, base, &refused))
4080    }
4081
4082    fn publish_on_fork(
4083        &self,
4084        cwd: &Path,
4085        branch: &str,
4086        base: &str,
4087        refused: &crate::error::SparError,
4088    ) -> Result<PushTarget> {
4089        let fork = self.fork().map_err(|e| {
4090            spar_err!(
4091                "{} spar could not publish it from a fork instead: {}",
4092                push_error(&PushTarget::Origin, branch, refused),
4093                e.last_line()
4094            )
4095        })?;
4096        let lease = self.fork_branch_lease(&fork, branch, base)?;
4097        let refused = refused.to_string();
4098        log!(
4099            "{} Publishing {branch} from the fork {} instead.",
4100            push_denial(&refused).unwrap_or("origin refused the push."),
4101            fork.full_name
4102        );
4103        let target = PushTarget::Fork(fork);
4104        // Not `push_to`: a lease on a branch a closed pull request left behind
4105        // replaces it, and that branch was never this HEAD's to contain.
4106        self.git_push(cwd, &target, branch, lease.as_deref())
4107            .map_err(|e| push_error(&target, branch, &e))?;
4108        Ok(target)
4109    }
4110
4111    /// What the first push of `branch` to the fork leases against.
4112    ///
4113    /// Nothing when the fork has no such branch. Its tip when a pull request
4114    /// already holds every commit on it beyond `base`, which is what a closed
4115    /// pull request leaves, since GitHub keeps the branch on the fork. Anything
4116    /// else is work only that branch has, and is refused the way an issue
4117    /// branch on origin is.
4118    fn fork_branch_lease(&self, fork: &Fork, branch: &str, base: &str) -> Result<Option<String>> {
4119        let live_ref = format!("refs/heads/{branch}");
4120        let unreadable = |e: crate::error::SparError| {
4121            spar_err!(
4122                "could not check whether the fork {} already has {branch}. {}",
4123                fork.full_name,
4124                e.last_line()
4125            )
4126        };
4127        let listed = self
4128            .git(&["ls-remote", "--heads", &fork.url, &live_ref])
4129            .map_err(unreadable)?;
4130        let Some(tip) = remote_head_oid(&listed, &live_ref)? else {
4131            return Ok(None);
4132        };
4133        let local = format!("refs/spar/fork/{branch}");
4134        self.git(&[
4135            "fetch",
4136            "--no-tags",
4137            &fork.url,
4138            &format!("+{live_ref}:{local}"),
4139        ])
4140        .map_err(unreadable)?;
4141        let ahead = self.commit_count_checked(&self.root, &local, base);
4142        let held = matches!(ahead, Ok(0)) || self.pull_request_holds(branch, &local, base);
4143        self.git_try(&["update-ref", "-d", &local]);
4144        let ahead = ahead?;
4145        if !held {
4146            bail!(
4147                "the fork {} already has {branch} with {ahead} commit(s) that are not on {base}, \
4148                 and no pull request accounts for them. Publishing over it would lose that work.\n\
4149                 Delete it with `git push {} --delete {branch}` if it is no longer needed, then \
4150                 run spar again.",
4151                fork.full_name,
4152                fork.url
4153            );
4154        }
4155        Ok(Some(tip))
4156    }
4157
4158    fn git_push(
4159        &self,
4160        cwd: &Path,
4161        target: &PushTarget,
4162        branch: &str,
4163        expected: Option<&str>,
4164    ) -> Result<()> {
4165        let refspec = format!("HEAD:refs/heads/{branch}");
4166        let lease = match (expected, target) {
4167            (Some(head), _) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
4168            // A fork is pushed to by URL, so there is no tracking ref for a
4169            // bare lease to read. Leasing against nothing makes the first push
4170            // a create that never moves a branch already there.
4171            (None, PushTarget::Fork(_)) => format!("--force-with-lease=refs/heads/{branch}:"),
4172            (None, PushTarget::Origin) => "--force-with-lease".to_string(),
4173        };
4174        self.git_at(Some(cwd), &["push", &lease, target.remote(), &refspec])
4175            .map(|_| ())
4176    }
4177
4178    /// The authenticated user's fork of this repository, made if there is none.
4179    ///
4180    /// GitHub answers a request to fork with the fork that already exists, so
4181    /// asking is safe either way, but it is a write and it is only made when
4182    /// looking found nothing. A new fork is copied in the background, so it is
4183    /// waited for before anything is pushed to it.
4184    fn fork(&self) -> Result<Fork> {
4185        match self.existing_fork() {
4186            Ok(Some(fork)) => return Ok(fork),
4187            Ok(None) => {}
4188            Err(e) => logdim!("{e} Asking GitHub to fork it, which returns any fork there is."),
4189        }
4190        let answer = self
4191            .gh(&["api", "-X", "POST", "repos/{owner}/{repo}/forks"])
4192            .map_err(|e| spar_err!("could not fork this repository. {}", e.last_line()))?;
4193        let fork = fork_from_api(&answer, &self.origin_push_url())?;
4194        self.wait_for_fork(&fork)?;
4195        Ok(self.fork.get_or_init(|| fork).clone())
4196    }
4197
4198    /// The authenticated user's fork of this repository, if there is one,
4199    /// found without making one.
4200    fn existing_fork(&self) -> Result<Option<Fork>> {
4201        if let Some(fork) = self.fork.get() {
4202            return Ok(Some(fork.clone()));
4203        }
4204        let answer = self
4205            .gh(&[
4206                "api",
4207                "graphql",
4208                "-F",
4209                "owner={owner}",
4210                "-F",
4211                "name={repo}",
4212                "-f",
4213                "query=query($owner: String!, $name: String!) { repository(owner: $owner, name: \
4214                 $name) { forks(first: 1, affiliations: [OWNER]) { nodes { nameWithOwner owner { \
4215                 login } sshUrl url } } } }",
4216                "--jq",
4217                ".data.repository.forks.nodes",
4218            ])
4219            .map_err(|e| {
4220                spar_err!(
4221                    "could not look for a fork of this repository. {}",
4222                    e.last_line()
4223                )
4224            })?;
4225        let found = fork_from_listing(&answer, &self.origin_push_url())?;
4226        Ok(found.map(|fork| self.fork.get_or_init(|| fork).clone()))
4227    }
4228
4229    /// Whether GitHub says the authenticated user cannot push here. False
4230    /// when it cannot say, so an unanswered question skips the early check
4231    /// rather than guessing.
4232    fn origin_refuses_push(&self) -> bool {
4233        self.gh_try(&["api", "repos/{owner}/{repo}", "--jq", ".permissions.push"])
4234            .trim()
4235            == "false"
4236    }
4237
4238    fn origin_push_url(&self) -> String {
4239        self.git_try(&["remote", "get-url", "--push", "origin"])
4240    }
4241
4242    /// Where spar can push a pull request's branch: origin for a branch in
4243    /// this repository, the fork for one on the authenticated user's own fork,
4244    /// such as one spar opened after origin refused it, and nowhere for
4245    /// anybody else's.
4246    pub fn pr_push_target(&self, pr: &PrView) -> Option<PushTarget> {
4247        if !pr.is_cross_repository {
4248            return Some(PushTarget::Origin);
4249        }
4250        let viewer = self.viewer_login().ok()?;
4251        own_fork_target(pr, viewer, &self.origin_push_url())
4252    }
4253
4254    fn wait_for_fork(&self, fork: &Fork) -> Result<()> {
4255        const TRIES: u32 = 30;
4256        let mut tries = 1;
4257        loop {
4258            match self.git(&["ls-remote", &fork.url, "HEAD"]) {
4259                Ok(_) => return Ok(()),
4260                Err(e) if tries >= TRIES => bail!(
4261                    "the fork {} could not be reached after {} seconds. {}",
4262                    fork.full_name,
4263                    TRIES * 2,
4264                    e.last_line()
4265                ),
4266                Err(_) => {
4267                    tries += 1;
4268                    std::thread::sleep(std::time::Duration::from_secs(2));
4269                }
4270            }
4271        }
4272    }
4273
4274    /// Refuse a push whose HEAD does not contain the head spar reviewed.
4275    ///
4276    /// The lease answers the remote moving. This answers the local side moving:
4277    /// an agent that reset or rebased the worktree leaves a HEAD that would
4278    /// publish over the reviewed head rather than on top of it, and the lease
4279    /// cannot tell the difference because the remote is exactly where spar left
4280    /// it.
4281    fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
4282        let head = self.head_oid_checked(cwd)?;
4283        if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
4284            return Ok(());
4285        }
4286        bail!(
4287            "refusing to push {head} because it does not contain the reviewed head {expected}. \
4288             The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
4289             the intended commits on top of it before resuming.",
4290            cwd.display()
4291        )
4292    }
4293
4294    /// Create one remote branch for a split without ever moving an existing ref.
4295    ///
4296    /// `worktree_for_split` chooses a name that is free locally and on origin,
4297    /// but another writer can still take it before the push. An empty expected
4298    /// value in the lease makes this an atomic create: it creates an absent ref,
4299    /// accepts an identical ref as a no-op, and never moves an existing ref.
4300    /// The shared `push` method cannot be used because its lease permits
4301    /// updating a ref fetched earlier.
4302    pub fn push_split_branch(
4303        &self,
4304        cwd: &Path,
4305        branch: &str,
4306    ) -> std::result::Result<(), SplitPushError> {
4307        let remote_ref = format!("refs/heads/{branch}");
4308        let lease = format!("--force-with-lease={remote_ref}:");
4309        let refspec = format!("HEAD:{remote_ref}");
4310        let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
4311            Ok(_) => Ok(()),
4312            Err(push_error) => {
4313                let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
4314                let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
4315                reconcile_failed_split_push(branch, push_error, local, remote)
4316            }
4317        };
4318        self.record_write(result)
4319    }
4320
4321    // -- gh ---------------------------------------------------------------
4322
4323    pub fn gh(&self, args: &[&str]) -> Result<String> {
4324        self.gh_at(None, args)
4325    }
4326
4327    pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
4328        let mut argv = vec!["gh".to_string()];
4329        argv.extend(args.iter().map(|s| s.to_string()));
4330        proc::run(
4331            &argv,
4332            &ExecOpts::new()
4333                .cwd(cwd.unwrap_or(&self.root))
4334                .timeout_secs(300),
4335        )
4336    }
4337
4338    /// Run gh with something on its stdin.
4339    ///
4340    /// A tracker body is far too long to pass on argv, and `--body-file -` is
4341    /// how gh takes one. `proc::exec` already wires the pipe, so this is a
4342    /// sibling of `gh_at` rather than anything new.
4343    pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
4344        let mut argv = vec!["gh".to_string()];
4345        argv.extend(args.iter().map(|s| s.to_string()));
4346        proc::run(
4347            &argv,
4348            &ExecOpts::new()
4349                .cwd(&self.root)
4350                .timeout_secs(300)
4351                .stdin(stdin),
4352        )
4353    }
4354
4355    pub fn gh_try(&self, args: &[&str]) -> String {
4356        let mut argv = vec!["gh".to_string()];
4357        argv.extend(args.iter().map(|s| s.to_string()));
4358        proc::run(
4359            &argv,
4360            &ExecOpts::new()
4361                .cwd(&self.root)
4362                .check(false)
4363                .timeout_secs(300),
4364        )
4365        .unwrap_or_default()
4366    }
4367
4368    /// `gh api -i`: the status line and headers with the body, and the exit
4369    /// code ignored on purpose. A `304 Not Modified`, which is the answer a
4370    /// conditional poll wants, makes gh exit 1 while printing exactly what is
4371    /// needed. Only a spawn failure or a timeout is an error here.
4372    pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
4373        let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
4374        for (name, value) in headers {
4375            argv.push("-H".to_string());
4376            argv.push(format!("{name}: {value}"));
4377        }
4378        argv.push(endpoint.to_string());
4379        let out = proc::exec(
4380            &argv,
4381            &ExecOpts::new()
4382                .cwd(&self.root)
4383                .check(false)
4384                .timeout_secs(300),
4385        )?;
4386        parse_api_response(&out.stdout)
4387            .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
4388    }
4389
4390    /// Every open pull request, with what a sweep needs to know about each.
4391    pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
4392        let text = self.gh(&[
4393            "pr",
4394            "list",
4395            "--state",
4396            "open",
4397            "--limit",
4398            "500",
4399            "--json",
4400            PR_SWEEP_FIELDS,
4401        ])?;
4402        serde_json::from_str(&text)
4403            .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
4404    }
4405
4406    /// One pull request, open or not, with what a sweep needs to know.
4407    pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
4408        let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
4409        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4410    }
4411
4412    /// Whether `login` is in a team, named `org/slug` as gh gives a team in
4413    /// `reviewRequests`. GitHub answers 404 for a team the login is not in, and
4414    /// a team the viewer is in is one it can always see. None when GitHub did
4415    /// not answer either way.
4416    pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
4417        // A team asked for a review belongs to the repository's own owner.
4418        let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
4419        if slug.is_empty() || login.is_empty() {
4420            return None;
4421        }
4422        let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
4423        let resp = self.gh_api_raw(&path, &[]).ok()?;
4424        match resp.status {
4425            200 => {
4426                let body: Value = serde_json::from_str(&resp.body).ok()?;
4427                Some(body.get("state")?.as_str()? == "active")
4428            }
4429            404 => Some(false),
4430            _ => None,
4431        }
4432    }
4433
4434    /// Every review request made on a pull request, oldest first. None when
4435    /// GitHub did not answer.
4436    ///
4437    /// A review spar posts as a comment leaves the request it answered open,
4438    /// so `reviewRequests` alone cannot tell that request from one made after
4439    /// the review. When it was made can.
4440    pub fn review_requests_made(&self, number: i64) -> Option<Vec<crate::model::ReviewRequested>> {
4441        let text = self
4442            .gh(&[
4443                "api",
4444                "graphql",
4445                "-F",
4446                "owner={owner}",
4447                "-F",
4448                "repo={repo}",
4449                "-F",
4450                &format!("number={number}"),
4451                "-f",
4452                &format!("query={REQUESTS_QUERY}"),
4453            ])
4454            .ok()?;
4455        parse_review_requests(&text)
4456    }
4457
4458    /// How many commits lie between two SHAs, when GitHub will say.
4459    pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
4460        if from.is_empty() || to.is_empty() {
4461            return None;
4462        }
4463        let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
4464        self.gh_try(&["api", &path, "--jq", ".total_commits"])
4465            .trim()
4466            .parse()
4467            .ok()
4468    }
4469
4470    /// `owner/name`, for the line that says what is being watched.
4471    pub fn repo_name(&self) -> Option<String> {
4472        let name = self.gh_try(&[
4473            "repo",
4474            "view",
4475            "--json",
4476            "nameWithOwner",
4477            "--jq",
4478            ".nameWithOwner",
4479        ]);
4480        let name = name.trim();
4481        (!name.is_empty()).then(|| name.to_string())
4482    }
4483
4484    pub fn listen_state_path(&self) -> PathBuf {
4485        self.root.join(STATE_DIR).join("state").join("listen.json")
4486    }
4487
4488    pub fn listen_lock_path(&self) -> PathBuf {
4489        self.root.join(STATE_DIR).join("state").join("listen.lock")
4490    }
4491
4492    /// The login `gh` is authenticated as.
4493    ///
4494    /// A hard error, never a degradation. Everything spar wrote has to be
4495    /// excluded from what it answers, and custody cannot be read from git
4496    /// authorship, so this is the only thing that tells spar's own comments
4497    /// from somebody else's. Without it the failure is not "answers a bit too
4498    /// much", it is a thread where spar answers itself until somebody notices.
4499    ///
4500    /// Not cached on disk: `gh auth switch` between runs would make a stored
4501    /// answer wrong in exactly the way that produces that thread.
4502    pub fn viewer_login(&self) -> Result<&str> {
4503        if let Some(login) = self.viewer.get() {
4504            return Ok(login);
4505        }
4506        let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
4507        let login = if !rest.trim().is_empty() {
4508            rest.trim().to_string()
4509        } else {
4510            // A token that cannot read /user can still answer for itself in
4511            // GraphQL, which is the case on some Enterprise installs.
4512            self.gh(&[
4513                "api",
4514                "graphql",
4515                "-f",
4516                "query={ viewer { login } }",
4517                "--jq",
4518                ".data.viewer.login",
4519            ])
4520            .map_err(|e| {
4521                spar_err!(
4522                    "could not find out who `gh` is authenticated as, so spar cannot tell its \
4523                     own comments from anybody else's. {}\nRun `gh auth status`.",
4524                    e.last_line()
4525                )
4526            })?
4527            .trim()
4528            .to_string()
4529        };
4530        if login.is_empty() {
4531            bail!("`gh` reported an empty login. Run `gh auth status`.");
4532        }
4533        Ok(self.viewer.get_or_init(|| login))
4534    }
4535
4536    /// One issue as it stands, open or closed.
4537    ///
4538    /// `fetch_issues` reads a queue to work: it drops a closed issue and fails
4539    /// when nothing survives. Both are wrong for reading one issue back, where
4540    /// closed is an answer and the empty case cannot arise.
4541    pub fn read_issue(&self, number: i64) -> Result<Issue> {
4542        let text = self
4543            .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4544            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4545        serde_json::from_str(&text)
4546            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
4547    }
4548
4549    pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
4550        let mut issues = Vec::new();
4551        for number in numbers {
4552            let text = self
4553                .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4554                .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4555            let issue: Issue = serde_json::from_str(&text)
4556                .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4557            if issue.is_closed() {
4558                crate::log!("issue #{number} is closed, skipping");
4559                continue;
4560            }
4561            issues.push(issue);
4562        }
4563        if issues.is_empty() {
4564            bail!("no open issues to work on");
4565        }
4566        Ok(issues)
4567    }
4568
4569    /// Open items, lowest numbered first, from `min_number` upward.
4570    ///
4571    /// The floor exists because a long lived repository accumulates a tail of
4572    /// old issues nobody is going to get to, and taking the lowest numbered
4573    /// open items means walking straight into them.
4574    fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4575        #[derive(Deserialize)]
4576        struct Row {
4577            number: i64,
4578        }
4579        let text = self.gh(&[
4580            kind,
4581            "list",
4582            "--state",
4583            "open",
4584            "--limit",
4585            &FETCH_CEILING.to_string(),
4586            "--json",
4587            "number",
4588        ])?;
4589        let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4590        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
4591        numbers.sort_unstable();
4592
4593        let noun = if kind == "issue" { "issues" } else { "PRs" };
4594        let found = numbers.len();
4595        if min_number > 0 {
4596            numbers.retain(|n| *n >= min_number);
4597            let skipped = found - numbers.len();
4598            if skipped > 0 {
4599                crate::log!("{skipped} open {noun} below #{min_number} skipped");
4600            }
4601        }
4602        if found >= FETCH_CEILING {
4603            crate::log!(
4604                "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
4605                 considered."
4606            );
4607        }
4608        if numbers.len() > limit {
4609            crate::log!(
4610                "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
4611                 explicitly.",
4612                numbers.len()
4613            );
4614            numbers.truncate(limit);
4615        }
4616        Ok(numbers)
4617    }
4618
4619    /// Open issues, lowest numbered first. `gh issue list` excludes PRs.
4620    pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4621        self.open_numbers("issue", limit, min_number)
4622    }
4623
4624    pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4625        self.open_numbers("pr", limit, min_number)
4626    }
4627
4628    pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
4629        self.branch_prs(branch, "open").into_iter().next()
4630    }
4631
4632    /// The open pull request for a branch, preserving a failed lookup as an
4633    /// error when the caller is deciding whether a write already landed.
4634    pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
4635        let text = self.gh(&[
4636            "pr",
4637            "list",
4638            "--head",
4639            branch,
4640            "--base",
4641            base,
4642            "--state",
4643            "open",
4644            "--json",
4645            "number,url,title,baseRefName",
4646        ])?;
4647        pr_for_base(&text, branch, base)
4648    }
4649
4650    /// Every pull request opened from this branch, merged and closed ones
4651    /// included, because a commit is preserved by whichever one carries it and
4652    /// that is rarely the newest.
4653    fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
4654        self.branch_prs(branch, "all")
4655    }
4656
4657    fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
4658        let text = self.gh_try(&[
4659            "pr",
4660            "list",
4661            "--head",
4662            branch,
4663            "--state",
4664            state,
4665            "--json",
4666            "number,url,title",
4667        ]);
4668        serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
4669    }
4670
4671    /// Whether a number names an issue or a pull request.
4672    ///
4673    /// `gh issue view` happily returns a pull request when handed its number,
4674    /// so it cannot be used to tell them apart. The issues API carries both and
4675    /// marks a pull request with a `pull_request` key, which is definitive.
4676    pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
4677        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
4678        let text = self
4679            .gh(&[
4680                "api",
4681                &path,
4682                "--jq",
4683                "if .pull_request then \"pr\" else \"issue\" end",
4684            ])
4685            .map_err(|e| {
4686                spar_err!(
4687                    "no issue or pull request #{number} in this repository. {}",
4688                    e.last_line()
4689                )
4690            })?;
4691        match text.trim() {
4692            "pr" => Ok(ItemKind::Pr),
4693            "issue" => Ok(ItemKind::Issue),
4694            other => Err(spar_err!(
4695                "could not tell whether #{number} is an issue or a pull request (got {other:?})"
4696            )),
4697        }
4698    }
4699
4700    /// An open pull request that would close this issue, whoever opened it.
4701    ///
4702    /// spar's own branch naming is checked first because it is exact and cheap.
4703    /// Falling back to GitHub's own issue linkage is what lets spar pick up a
4704    /// pull request a person started on a branch named anything at all.
4705    ///
4706    /// An error rather than `None` when the question could not be asked. The
4707    /// two answers are not interchangeable: "no pull request exists" is the one
4708    /// that makes spar implement over the top of somebody's work and open a
4709    /// second pull request for the same issue.
4710    pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
4711        let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
4712        if let Some(pr) = mine {
4713            return Ok(Some(pr));
4714        }
4715        let listed = match self.gh(&[
4716            "pr",
4717            "list",
4718            "--state",
4719            "open",
4720            "--limit",
4721            &FETCH_CEILING.to_string(),
4722            "--json",
4723            "number,url,title,baseRefName,closingIssuesReferences",
4724        ]) {
4725            Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
4726                spar_err!("could not read the pull request list while looking for #{issue}")
4727            })?,
4728            Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
4729        };
4730        if listed.len() >= FETCH_CEILING {
4731            logwarn!(
4732                "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a                  pull request past it cannot be seen from here."
4733            );
4734        }
4735        let default = self.default_branch(base);
4736        if !base.eq_ignore_ascii_case(&default) {
4737            // GitHub populates closingIssuesReferences only for pull requests
4738            // targeting the default branch, so on any other base the linkage is
4739            // silence rather than an answer.
4740            logwarn!(
4741                "#{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."
4742            );
4743        }
4744        Ok(pick_linked_pr(&listed, issue))
4745    }
4746
4747    /// spar's own branch, with a failed lookup kept as a failure.
4748    fn pr_for_branch_failing_closed(
4749        &self,
4750        branch: &str,
4751        base: &str,
4752        issue: i64,
4753    ) -> Result<Option<PrRef>> {
4754        match self.try_pr_for_branch(branch, base) {
4755            Ok(found) => Ok(found),
4756            Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
4757        }
4758    }
4759
4760    /// What to do when GitHub would not say whether a pull request exists.
4761    ///
4762    /// An error, because "no pull request exists" is the answer that makes spar
4763    /// implement from scratch and open a second pull request over somebody's
4764    /// work. The one exception is a repository gh cannot reach at all: there is
4765    /// no pull request to collide with when there is no GitHub repository in
4766    /// play, and every later write would fail anyway. That case is a warning and
4767    /// a `None`, so a run against a plain git remote behaves as it always did.
4768    fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
4769        if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
4770            return Err(spar_err!(
4771                "could not check whether a pull request already exists for #{issue}: {}. \
4772                 Refusing to implement over the top of work that may already be open.",
4773                e.last_line()
4774            ));
4775        }
4776        logwarn!(
4777            "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
4778             whether a pull request already exists for it.",
4779            e.last_line()
4780        );
4781        Ok(())
4782    }
4783
4784    pub fn pr_view(&self, number: i64) -> Result<PrView> {
4785        let text = self.gh(&[
4786            "pr",
4787            "view",
4788            &number.to_string(),
4789            "--json",
4790            "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,\
4791             isCrossRepository,headRepositoryOwner,headRepository",
4792        ])?;
4793        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4794    }
4795
4796    fn try_pr_state(&self, number: i64) -> Result<String> {
4797        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
4798        serde_json::from_str::<Value>(text.trim())
4799            .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
4800            .get("state")
4801            .and_then(Value::as_str)
4802            .map(str::to_string)
4803            .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
4804    }
4805
4806    pub fn pr_state(&self, number: i64) -> String {
4807        self.try_pr_state(number).unwrap_or_default()
4808    }
4809
4810    /// The commit currently exposed as a pull request's head.
4811    pub fn pr_head_oid(&self, number: i64) -> Result<String> {
4812        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
4813        let oid = serde_json::from_str::<Value>(&text)
4814            .ok()
4815            .and_then(|value| {
4816                value
4817                    .get("headRefOid")
4818                    .and_then(Value::as_str)
4819                    .map(str::trim)
4820                    .filter(|oid| !oid.is_empty())
4821                    .map(str::to_string)
4822            })
4823            .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
4824        Ok(oid)
4825    }
4826
4827    /// Where a pull request's head stands now: what GitHub's pull request
4828    /// names, and, for a branch in this repository, where `origin` has the
4829    /// branch. The two differ until GitHub catches up with a push.
4830    pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
4831        let pull = self.pr_head_oid(pr.number)?;
4832        let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
4833            None
4834        } else {
4835            let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
4836            let listed = self
4837                .git(&["ls-remote", "--heads", "origin", &remote_ref])
4838                .map_err(|e| {
4839                    spar_err!(
4840                        "could not read where {} is on origin: {}",
4841                        pr.head_ref_name.trim(),
4842                        e.last_line()
4843                    )
4844                })?;
4845            remote_head_oid(&listed, &remote_ref)?
4846        };
4847        Ok(LiveHead { pull, branch })
4848    }
4849
4850    /// Open a pull request from `branch`, published at `target`.
4851    ///
4852    /// The lookup that confirms it landed goes by branch name alone, which
4853    /// `gh pr list --head` matches on a fork's pull request too.
4854    pub fn create_pr(
4855        &self,
4856        cwd: &Path,
4857        branch: &str,
4858        target: &PushTarget,
4859        base: &str,
4860        title: &str,
4861        body: &str,
4862    ) -> Result<PrRef> {
4863        let title = self.record_failed_write(self.clean_title(title))?;
4864        let body = self.record_failed_write(self.clean(body))?;
4865        let head = target.head(branch);
4866        let mut argv = vec![
4867            "pr", "create", "--base", base, "--head", &head, "--title", &title, "--body", &body,
4868        ];
4869        if self.drafts != Drafts::Never {
4870            argv.push("--draft");
4871        }
4872        let created = self.gh_at(Some(cwd), &argv);
4873        let found = self.try_pr_for_branch(branch, base);
4874        self.record_write(reconcile_pr_creation(branch, created, found))
4875    }
4876
4877    pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
4878        let body = signed(&self.record_failed_write(self.clean(body))?);
4879        let comments = self.record_failed_write(self.try_issue_comments(number))?;
4880        if has_exact_comment(&comments, &body) {
4881            return Ok(());
4882        }
4883        let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
4884        let result = match posted {
4885            Ok(_) => Ok(()),
4886            Err(post_error) => {
4887                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
4888            }
4889        };
4890        self.record_write(result)
4891    }
4892
4893    /// Whether the viewer wrote this pull request. GitHub refuses to let an
4894    /// author approve or request changes on their own, so a verdict on one can
4895    /// only ever be a comment.
4896    fn pr_is_own(&self, number: i64) -> Result<bool> {
4897        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
4898        let author = serde_json::from_str::<Value>(&text)
4899            .ok()
4900            .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
4901            .unwrap_or_default();
4902        let viewer = self.viewer_login()?.to_string();
4903        Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
4904    }
4905
4906    /// Submit a formal review, or a comment carrying the verdict's words when
4907    /// GitHub will not take the verdict: the viewer's own pull request, or no
4908    /// verdict at all.
4909    ///
4910    /// Mirrors `comment_pr` step for step: the style gate, the signature, a
4911    /// duplicate check against the viewer's own reviews, the write, and a read
4912    /// back on failure so an unverifiable write is reported as one.
4913    ///
4914    /// `commit` is the commit the reviewers read. GitHub attaches a review
4915    /// that names no commit to whatever the head is when it arrives, so a push
4916    /// made while a review ran would otherwise receive an approval nobody
4917    /// gave it. `gh pr review` cannot name one, hence the REST call.
4918    ///
4919    /// `comments` go on their lines in the same review, so they arrive with
4920    /// it or not at all. GitHub refuses the whole review over one line its diff
4921    /// does not show, so when it refused this one and the read back finds
4922    /// nothing landed, the review goes up again without them: the body already
4923    /// says everything they do. Any other failure is reconciled as a review
4924    /// without them would be, never retried: a gateway error can arrive while
4925    /// GitHub is still saving a review that will land.
4926    pub fn submit_review(
4927        &self,
4928        number: i64,
4929        verdict: Option<crate::model::ReviewVerdict>,
4930        body: &str,
4931        commit: &str,
4932        comments: &[InlineComment],
4933    ) -> Result<()> {
4934        let own = match verdict {
4935            Some(_) => self.record_failed_write(self.pr_is_own(number))?,
4936            None => false,
4937        };
4938        let text = match (verdict, own) {
4939            (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
4940            _ => body.to_string(),
4941        };
4942        let body = signed(&self.record_failed_write(self.clean(&text))?);
4943        let mut inline = Vec::with_capacity(comments.len());
4944        for c in comments {
4945            inline.push(InlineComment {
4946                body: signed(&self.record_failed_write(self.clean(&c.body))?),
4947                ..c.clone()
4948            });
4949        }
4950        let viewer = self.record_failed_write(self.viewer_login())?.to_string();
4951        let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
4952        if has_viewer_review(&reviews, &viewer, &body, commit) {
4953            return Ok(());
4954        }
4955        let event = match (verdict, own) {
4956            (Some(v), false) => v.api_event(),
4957            _ => "COMMENT",
4958        };
4959        let result = if inline.is_empty() {
4960            self.post_review(number, &body, &viewer, commit, event)
4961        } else {
4962            let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
4963            let payload = review_payload(commit, event, &body, &inline).to_string();
4964            match self.gh_stdin(
4965                &["api", "-X", "POST", &path, "--input", "-", "--silent"],
4966                &payload,
4967            ) {
4968                Ok(_) => Ok(()),
4969                Err(post_error) if !refused(&post_error) => reconcile_review_post(
4970                    number,
4971                    &body,
4972                    &viewer,
4973                    commit,
4974                    post_error,
4975                    self.try_pr_reviews(number),
4976                ),
4977                Err(post_error) => match self.try_pr_reviews(number) {
4978                    Ok(reviews) if !has_viewer_review(&reviews, &viewer, &body, commit) => {
4979                        logwarn!(
4980                            "the review of #{number} with its line comments did not go up ({}), \
4981                             so it is going up with every finding in its body only",
4982                            post_error.last_line()
4983                        );
4984                        self.post_review(number, &body, &viewer, commit, event)
4985                    }
4986                    reviews => {
4987                        reconcile_review_post(number, &body, &viewer, commit, post_error, reviews)
4988                    }
4989                },
4990            }
4991        };
4992        self.record_write(result)
4993    }
4994
4995    /// A review with its body alone, read back if the post reports a failure.
4996    fn post_review(
4997        &self,
4998        number: i64,
4999        body: &str,
5000        viewer: &str,
5001        commit: &str,
5002        event: &str,
5003    ) -> Result<()> {
5004        let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5005        let posted = self.gh(&[
5006            "api",
5007            "-X",
5008            "POST",
5009            &path,
5010            "-f",
5011            &format!("commit_id={commit}"),
5012            "-f",
5013            &format!("event={event}"),
5014            "-f",
5015            &format!("body={body}"),
5016            "--silent",
5017        ]);
5018        match posted {
5019            Ok(_) => Ok(()),
5020            Err(post_error) => reconcile_review_post(
5021                number,
5022                body,
5023                viewer,
5024                commit,
5025                post_error,
5026                self.try_pr_reviews(number),
5027            ),
5028        }
5029    }
5030
5031    pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
5032        let body = signed(&self.record_failed_write(self.clean(body))?);
5033        let comments = self.record_failed_write(self.try_issue_comments(number))?;
5034        if has_exact_comment(&comments, &body) {
5035            return Ok(());
5036        }
5037        let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
5038        let result = match posted {
5039            Ok(_) => Ok(()),
5040            Err(post_error) => {
5041                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5042            }
5043        };
5044        self.record_write(result)
5045    }
5046
5047    /// Comment, then close as not planned.
5048    ///
5049    /// Only ever called when both agents independently declined the issue: one
5050    /// agent's opinion is not enough to close somebody's report.
5051    pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
5052        self.comment_issue(number, body)?;
5053        let n = number.to_string();
5054        let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
5055            Ok(_) => Ok(()),
5056            // Older gh builds do not take --reason.
5057            Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
5058                spar_err!(
5059                    "commented on #{number} but could not close it: {}",
5060                    e.last_line()
5061                )
5062            }),
5063        };
5064        self.record_write(closed)
5065    }
5066
5067    /// Replace an issue body, refusing unless it is still byte for byte what
5068    /// the caller read and validating only the fragment spar inserted.
5069    ///
5070    /// The only place spar rewrites text somebody else wrote, so the check is
5071    /// the whole point: an edit computed from a body that has since moved would
5072    /// silently delete whatever moved it. The caller decides whether another
5073    /// attempt is safe for its workflow.
5074    ///
5075    /// Deliberately not through `clean_issue_body`. The body is mostly a
5076    /// person's own prose, and the scrub would rewrite their punctuation while
5077    /// the length budget could truncate the end of a long report. `inserted` is
5078    /// the only text here spar is answerable for, so it still passes through the
5079    /// style gate. The full body travels over stdin because a tracker can be far
5080    /// too long for one argument.
5081    pub fn edit_issue_body(
5082        &self,
5083        number: i64,
5084        expected: &str,
5085        body: &str,
5086        inserted: &str,
5087    ) -> Result<()> {
5088        let cleaned = self.record_failed_write(self.clean(inserted))?;
5089        if cleaned.trim() != inserted.trim() {
5090            return self.record_failed_write(Err(spar_err!(
5091                "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
5092            )));
5093        }
5094        let current = self.record_failed_write(self.issue_body(number))?;
5095        if current != expected {
5096            return self.record_failed_write(Err(spar_err!(
5097                "the body of #{number} changed since it was read, so it was left alone rather \
5098                 than written over."
5099            )));
5100        }
5101        let edited = self.gh_stdin(
5102            &["issue", "edit", &number.to_string(), "--body-file", "-"],
5103            body,
5104        );
5105        let result = match edited {
5106            Ok(_) => Ok(()),
5107            Err(edit_error) => {
5108                reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
5109            }
5110        };
5111        self.record_write(result)
5112    }
5113
5114    /// One issue's body, exactly as GitHub holds it.
5115    pub fn issue_body(&self, number: i64) -> Result<String> {
5116        #[derive(Deserialize)]
5117        struct Row {
5118            #[serde(default)]
5119            body: Option<String>,
5120        }
5121        let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
5122        let row: Row = serde_json::from_str(text.trim())
5123            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
5124        Ok(row.body.unwrap_or_default())
5125    }
5126
5127    /// Every open issue with its title and body, in one call.
5128    ///
5129    /// For a screen that has to say something about each of twenty items before
5130    /// anything expensive happens. One call rather than one per issue.
5131    pub fn open_issue_rows(&self) -> Vec<Issue> {
5132        let text = self.gh_try(&[
5133            "issue",
5134            "list",
5135            "--state",
5136            "open",
5137            "--limit",
5138            &FETCH_CEILING.to_string(),
5139            "--json",
5140            "number,title,body,labels,state,stateReason,url",
5141        ]);
5142        serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
5143    }
5144
5145    /// Every open pull request with its size, in one call.
5146    pub fn open_pr_rows(&self) -> Vec<PrRow> {
5147        let text = self.gh_try(&[
5148            "pr",
5149            "list",
5150            "--state",
5151            "open",
5152            "--limit",
5153            &FETCH_CEILING.to_string(),
5154            "--json",
5155            "number,title,changedFiles,additions,deletions",
5156        ]);
5157        serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
5158    }
5159
5160    pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
5161        self.create_issue_apart_from(title, body, None)
5162    }
5163
5164    pub fn create_issue_apart_from(
5165        &self,
5166        title: &str,
5167        body: &str,
5168        apart_from: Option<i64>,
5169    ) -> Result<String> {
5170        let title = self.record_failed_write(self.clean_title(title))?;
5171        let body = self.record_failed_write(self.clean_issue_body(body))?;
5172        let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
5173        let result = match created {
5174            Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
5175            created => {
5176                let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
5177                reconcile_issue_creation(&title, created, found)
5178            }
5179        };
5180        self.record_write(result)
5181    }
5182}
5183
5184/// An issue that already covers what spar was about to file.
5185#[derive(Debug, Clone)]
5186pub struct ExistingIssue {
5187    pub number: i64,
5188    pub url: String,
5189    pub title: String,
5190    pub body: String,
5191    pub open: bool,
5192}
5193
5194impl Repo {
5195    pub(crate) fn try_exact_issue_apart_from(
5196        &self,
5197        title: &str,
5198        body: &str,
5199        apart_from: Option<i64>,
5200    ) -> Result<Option<ExistingIssue>> {
5201        #[derive(Deserialize)]
5202        #[serde(rename_all = "camelCase")]
5203        struct Row {
5204            number: i64,
5205            #[serde(default)]
5206            title: String,
5207            #[serde(default)]
5208            url: String,
5209            #[serde(default)]
5210            body: Option<String>,
5211            #[serde(default)]
5212            state: String,
5213        }
5214
5215        let text = self.gh(&[
5216            "issue",
5217            "list",
5218            "--state",
5219            "all",
5220            "--limit",
5221            "100",
5222            "--json",
5223            "number,title,url,body,state,stateReason",
5224        ])?;
5225        let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5226            .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
5227        Ok(rows
5228            .into_iter()
5229            .filter(|row| Some(row.number) != apart_from)
5230            .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
5231            .map(|row| ExistingIssue {
5232                number: row.number,
5233                url: row.url,
5234                title: row.title,
5235                body: row.body.unwrap_or_default(),
5236                open: row.state.eq_ignore_ascii_case("open"),
5237            }))
5238    }
5239
5240    /// An issue that already describes this defect, however it was worded.
5241    ///
5242    /// Exact title matching let duplicates through: two agents, or two runs a
5243    /// week apart, never word one defect identically. A real run filed two
5244    /// duplicates that way, and each had to be closed by hand afterwards.
5245    /// Titles alone are too thin to match on, so this compares titles and
5246    /// bodies together.
5247    pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
5248        self.find_similar_issue_apart_from(title, body, None)
5249    }
5250
5251    /// The same search, with one issue that cannot be its own duplicate.
5252    ///
5253    /// A tracker's body quotes every item in its checklist, so searching for an
5254    /// item's words matches the tracker before it matches anything else. That
5255    /// would link an item to the issue it is written in.
5256    /// The search a tracker runs, which wants closed issues too: a checklist
5257    /// item matching a closed issue is a done item that wants a link.
5258    pub fn find_similar_issue_apart_from(
5259        &self,
5260        title: &str,
5261        body: &str,
5262        apart_from: Option<i64>,
5263    ) -> Option<ExistingIssue> {
5264        self.try_similar_issue(title, body, apart_from, true)
5265            .ok()
5266            .flatten()
5267    }
5268
5269    /// The search a filing runs, over open issues only.
5270    pub fn try_find_similar_issue_apart_from(
5271        &self,
5272        title: &str,
5273        body: &str,
5274        apart_from: Option<i64>,
5275    ) -> Result<Option<ExistingIssue>> {
5276        self.try_similar_issue(title, body, apart_from, false)
5277    }
5278
5279    /// The same search, preserving lookup failure for a caller about to write.
5280    fn try_similar_issue(
5281        &self,
5282        title: &str,
5283        body: &str,
5284        apart_from: Option<i64>,
5285        include_closed: bool,
5286    ) -> Result<Option<ExistingIssue>> {
5287        #[derive(Deserialize)]
5288        #[serde(rename_all = "camelCase")]
5289        struct Row {
5290            number: i64,
5291            #[serde(default)]
5292            title: String,
5293            #[serde(default)]
5294            url: String,
5295            #[serde(default)]
5296            body: String,
5297            #[serde(default)]
5298            state: String,
5299        }
5300        if title.trim().is_empty() {
5301            return Ok(None);
5302        }
5303        // Search on the title's own words: GitHub's index is the cheap way to
5304        // narrow the field before comparing properly.
5305        let query: String = title
5306            .chars()
5307            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5308            .take(120)
5309            .collect();
5310        // Open issues only when something is about to be filed. A fuzzy match
5311        // against a closed one is the case worth keeping rather than dropping:
5312        // a defect that was fixed, closed, and has come back is exactly the
5313        // finding a maintainer wants, and "#N covers it and is closed" wrote it
5314        // nowhere. An exact match is still checked against every state, since
5315        // that is a regression of one specific issue rather than a resemblance
5316        // to one, and a tracker still searches closed issues because a
5317        // checklist item matching one is a done item that wants a link.
5318        let state = if include_closed { "all" } else { "open" };
5319        let text = self.gh(&[
5320            "issue",
5321            "list",
5322            "--state",
5323            state,
5324            "--limit",
5325            "100",
5326            "--search",
5327            query.trim(),
5328            "--json",
5329            "number,title,url,body,state,stateReason",
5330        ])?;
5331        let rows: Vec<Row> = serde_json::from_str(text.trim())
5332            .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
5333
5334        Ok(rows
5335            .into_iter()
5336            .filter(|row| Some(row.number) != apart_from)
5337            .find(|row| {
5338                row.title.trim().eq_ignore_ascii_case(title.trim())
5339                    || textsim::covers(title, body, &row.title, &row.body)
5340            })
5341            .map(|row| ExistingIssue {
5342                number: row.number,
5343                url: row.url,
5344                title: row.title,
5345                open: row.state.eq_ignore_ascii_case("open"),
5346                body: row.body,
5347            }))
5348    }
5349
5350    /// Avoid filing a duplicate when a follow-up already exists.
5351    pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
5352        #[derive(Deserialize)]
5353        struct Row {
5354            title: String,
5355            url: String,
5356        }
5357        let needle = title.trim().to_lowercase();
5358        if needle.is_empty() {
5359            return None;
5360        }
5361        // Quotes and newlines would be read as search syntax rather than text.
5362        let query: String = title
5363            .chars()
5364            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5365            .take(120)
5366            .collect();
5367        let text = self.gh_try(&[
5368            "issue",
5369            "list",
5370            "--state",
5371            "all",
5372            "--limit",
5373            "100",
5374            "--search",
5375            query.trim(),
5376            "--json",
5377            "number,title,url",
5378        ]);
5379        serde_json::from_str::<Vec<Row>>(text.trim())
5380            .ok()?
5381            .into_iter()
5382            .find(|row| row.title.trim().to_lowercase() == needle)
5383            .map(|row| row.url)
5384    }
5385
5386    /// Squash merge, tolerating cleanup failures after a successful merge.
5387    ///
5388    /// Take a pull request out of draft, once the review has converged.
5389    ///
5390    /// Best effort for the remaining workflow. A failure does not discard the
5391    /// review or stop later independent work, but the final write summary
5392    /// reports it and the command returns non-zero.
5393    pub fn mark_ready(&self, number: i64) -> bool {
5394        match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
5395            Ok(_) => true,
5396            Err(e) => {
5397                logdim!(
5398                    "PR #{number} is approved but could not be taken out of draft: {}",
5399                    e.last_line()
5400                );
5401                false
5402            }
5403        }
5404    }
5405
5406    /// The worktree that has `branch` checked out, if one does.
5407    fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
5408        let listed = self
5409            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
5410            .ok()?;
5411        let wanted = format!("branch refs/heads/{branch}");
5412        let mut current: Option<PathBuf> = None;
5413        for line in listed.lines() {
5414            if let Some(path) = line.strip_prefix("worktree ") {
5415                current = Some(PathBuf::from(path));
5416            } else if line == wanted {
5417                return current;
5418            }
5419        }
5420        None
5421    }
5422
5423    /// Finish the local cleanup a merge could not.
5424    ///
5425    /// `gh pr merge --delete-branch` gives up on the local branch whenever any
5426    /// worktree has it checked out, which is the ordinary state rather than an
5427    /// odd one: `spar run` opens the pull request from
5428    /// `.spar-worktrees/issue-N` and leaves the checkout there, and the merge
5429    /// usually happens in a later `spar resume` working somewhere else. The
5430    /// next run's sweep tidied it, so nothing was ever lost, but the run that
5431    /// did the merge reported a failure about a branch nobody will touch again
5432    /// and left a checkout for the next run to explain.
5433    ///
5434    /// Held to the same rules the sweep uses, because they are the rules that
5435    /// decide whether anything would be lost: a branch spar recorded creating,
5436    /// a worktree of spar's own with no recoverable work in it, and a tip some
5437    /// surviving ref preserves. Anything else is left exactly where it is.
5438    fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
5439        let branch = self.pr_view(number).ok()?.head_ref_name;
5440        if branch.is_empty() || !self.known_branches().contains_key(&branch) {
5441            return None;
5442        }
5443        let mut released = None;
5444        if let Some(path) = self.worktree_holding(&branch) {
5445            if !path.starts_with(self.root.join(WORKTREE_DIR)) {
5446                return None;
5447            }
5448            if !matches!(
5449                self.has_recoverable_work(&path, Disposable::BuildOutput),
5450                Ok(false)
5451            ) {
5452                return None;
5453            }
5454            match self.remove_worktree_at(&path) {
5455                Ok(true) => released = Some(path),
5456                _ => return None,
5457            }
5458        }
5459        match self.delete_branch_if_safe(&branch) {
5460            Ok(true) => {
5461                self.forget_branch(&branch);
5462                Some(match released {
5463                    Some(path) => format!(
5464                        " The worktree at {} was released and branch {branch} deleted.",
5465                        path.display()
5466                    ),
5467                    None => format!(" Branch {branch} was deleted."),
5468                })
5469            }
5470            _ => released.map(|path| {
5471                format!(
5472                    " The worktree at {} was released, but branch {branch} was kept.",
5473                    path.display()
5474                )
5475            }),
5476        }
5477    }
5478
5479    /// `gh pr merge --delete-branch` exits non-zero when it cannot delete the
5480    /// local branch, which happens *after* the merge has already landed.
5481    /// Treating that as a failure reports work as lost when it is not.
5482    pub fn merge_pr(&self, number: i64) -> Result<()> {
5483        let n = number.to_string();
5484        let merged = match self.gh(&merge_pr_args(&n, None, true)) {
5485            Ok(_) => Ok(()),
5486            Err(e) => {
5487                if self.pr_state(number) == "MERGED" {
5488                    let finished = self
5489                        .finish_merged_branch_cleanup(number)
5490                        .unwrap_or_default();
5491                    logdim!(
5492                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5493                        e.last_line()
5494                    );
5495                    Ok(())
5496                } else {
5497                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5498                }
5499            }
5500        };
5501        self.record_write(merged)
5502    }
5503
5504    /// Squash merge only if the pull request still exposes the reviewed head.
5505    pub fn merge_pr_at_head(
5506        &self,
5507        number: i64,
5508        expected_head: &str,
5509        delete_branch: bool,
5510    ) -> Result<()> {
5511        let n = number.to_string();
5512        let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
5513            Ok(_) => Ok(()),
5514            Err(e) => {
5515                if self.pr_state(number) == "MERGED" {
5516                    let finished = self
5517                        .finish_merged_branch_cleanup(number)
5518                        .unwrap_or_default();
5519                    logdim!(
5520                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5521                        e.last_line()
5522                    );
5523                    Ok(())
5524                } else {
5525                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5526                }
5527            }
5528        };
5529        self.record_write(merged)
5530    }
5531
5532    // -- follow-ups -------------------------------------------------------
5533
5534    /// The queue of follow-ups recorded locally rather than filed, which
5535    /// `spar followup` works.
5536    pub fn followups_path(&self) -> PathBuf {
5537        self.root.join(STATE_DIR).join("followups.md")
5538    }
5539
5540    /// Where `spar brainstorm` writes a session unless told otherwise.
5541    ///
5542    /// Under the state directory, so it is kept out of the repository's own
5543    /// `git status`; `--out` puts one somewhere a person would commit.
5544    pub fn brainstorms_dir(&self) -> PathBuf {
5545        self.root.join(STATE_DIR).join("brainstorms")
5546    }
5547
5548    /// What `spar followup` already dealt with, kept beside the queue.
5549    ///
5550    /// Two jobs. It is what stops `append_local_followup` re-recording a
5551    /// follow-up whose entry has since left the queue, which would otherwise
5552    /// turn the file into a ring buffer of things already filed. And it keeps
5553    /// the text of an entry a screening pass ruled stale, so a wrong verdict
5554    /// costs a re-read rather than the only copy of a real defect.
5555    pub fn worked_followups_path(&self) -> PathBuf {
5556        self.root.join(STATE_DIR).join("followups.done.md")
5557    }
5558
5559    /// What `spar checkin` has already answered on one pull request or issue.
5560    /// Where what a run spent is kept.
5561    pub fn spend_path(&self) -> PathBuf {
5562        self.root.join(STATE_DIR).join("state").join("spend.json")
5563    }
5564
5565    /// Append this run's calls to the record.
5566    ///
5567    /// Appended rather than replaced, so a resumed pull request adds to the
5568    /// count of what it has cost so far rather than starting it again, and so
5569    /// anybody charting this has a file to chart.
5570    pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
5571        if calls.is_empty() {
5572            return Ok(());
5573        }
5574        let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
5575            .ok()
5576            .and_then(|text| serde_json::from_str(&text).ok())
5577            .unwrap_or_default();
5578        all.extend(calls.iter().cloned());
5579        if let Some(parent) = self.spend_path().parent() {
5580            let _ = std::fs::create_dir_all(parent);
5581        }
5582        write_json_atomic(&self.spend_path(), &all)
5583    }
5584
5585    /// Who wrote the first draft of each issue, by number.
5586    ///
5587    /// Custody alternates within a pull request and the record of it lives in
5588    /// that pull request's state. This is the other question, which nothing
5589    /// answered: over a queue, which model authored what.
5590    pub fn implementors_path(&self) -> PathBuf {
5591        self.root
5592            .join(STATE_DIR)
5593            .join("state")
5594            .join("implementors.json")
5595    }
5596
5597    pub fn read_implementors(&self) -> BTreeMap<i64, String> {
5598        std::fs::read_to_string(self.implementors_path())
5599            .ok()
5600            .and_then(|text| serde_json::from_str(&text).ok())
5601            .unwrap_or_default()
5602    }
5603
5604    /// Best effort: this is a record for a person, not something the loop reads
5605    /// back, so a failure to write it is not a reason to stop.
5606    pub fn remember_implementor(&self, issue: i64, agent: &str) {
5607        let mut known = self.read_implementors();
5608        known.insert(issue, agent.to_string());
5609        if let Some(parent) = self.implementors_path().parent() {
5610            let _ = std::fs::create_dir_all(parent);
5611        }
5612        if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
5613            logdim!("could not record who implemented #{issue}: {e}");
5614        }
5615    }
5616
5617    /// Where the issues triage disagreed about are remembered.
5618    ///
5619    /// One file rather than one per issue: it is read once per run, before any
5620    /// triage call, to decide what not to ask about again.
5621    pub fn contested_path(&self) -> PathBuf {
5622        self.root
5623            .join(STATE_DIR)
5624            .join("state")
5625            .join("contested.json")
5626    }
5627
5628    /// The issues a person has been asked to decide, by number.
5629    pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
5630        std::fs::read_to_string(self.contested_path())
5631            .ok()
5632            .and_then(|text| serde_json::from_str(&text).ok())
5633            .unwrap_or_default()
5634    }
5635
5636    /// Remember an issue triage could not agree on.
5637    ///
5638    /// Best effort: a run that cannot write this asks again next time, which is
5639    /// what it did before this existed.
5640    pub fn remember_contested(&self, items: &[ContestedItem]) {
5641        if items.is_empty() {
5642            return;
5643        }
5644        let mut known = self.read_contested();
5645        for item in items {
5646            known.insert(
5647                item.issue,
5648                Contested {
5649                    title: item.title.clone(),
5650                    positions: item.positions.clone(),
5651                    reasons: item.reasons.clone(),
5652                },
5653            );
5654        }
5655        if let Some(parent) = self.contested_path().parent() {
5656            let _ = std::fs::create_dir_all(parent);
5657        }
5658        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5659            logdim!("could not record the contested issues: {e}");
5660        }
5661    }
5662
5663    /// Ask about them again, because a person has acted on them.
5664    pub fn forget_contested(&self, numbers: &[i64]) {
5665        let mut known = self.read_contested();
5666        let before = known.len();
5667        known.retain(|number, _| !numbers.contains(number));
5668        if known.len() == before {
5669            return;
5670        }
5671        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5672            logdim!("could not update the contested issues: {e}");
5673        }
5674    }
5675
5676    pub fn checkin_state_path(&self, number: i64) -> PathBuf {
5677        self.root
5678            .join(STATE_DIR)
5679            .join("state")
5680            .join(format!("checkin-{number}.json"))
5681    }
5682
5683    /// Where `spar review` records what it reviewed, and a second look reads it.
5684    pub fn review_record_path(&self, number: i64) -> PathBuf {
5685        self.root
5686            .join(STATE_DIR)
5687            .join("state")
5688            .join(format!("review-{number}.json"))
5689    }
5690
5691    /// None when there is no record, or when it cannot be read. A record spar
5692    /// cannot read is one it reviews afresh over, and the log says so.
5693    pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
5694        let path = self.review_record_path(number);
5695        let text = std::fs::read_to_string(&path).ok()?;
5696        match serde_json::from_str(&text) {
5697            Ok(record) => Some(record),
5698            Err(e) => {
5699                logdim!("could not read {}: {e}", path.display());
5700                None
5701            }
5702        }
5703    }
5704
5705    pub fn write_review_record(
5706        &self,
5707        number: i64,
5708        record: &crate::model::ReviewRecord,
5709    ) -> Result<()> {
5710        write_json_atomic(&self.review_record_path(number), record)
5711    }
5712
5713    /// Append a follow-up to a local note instead of the tracker.
5714    ///
5715    /// Deduplicated on the title, matching the issue path. The body arrives
5716    /// with its provenance already stamped by the caller, so nothing is added
5717    /// here.
5718    ///
5719    /// A write that did not happen is reported as such rather than as a
5720    /// duplicate: the caller settles the point on the strength of this answer,
5721    /// and settling it on a failed write is how a real defect is lost.
5722    ///
5723    /// Both files are checked, because `spar followup` removes an entry from
5724    /// the queue once it has filed it. Checking only the queue would let the
5725    /// next run that rediscovers the same defect append it again, on top of the
5726    /// issue that now exists for it.
5727    pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
5728        let path = self.followups_path();
5729        let heading = format!("## {}", title.trim());
5730        for seen in [&path, &self.worked_followups_path()] {
5731            if let Ok(existing) = std::fs::read_to_string(seen) {
5732                if has_heading(&existing, title) {
5733                    logdim!("follow-up already noted: {title}");
5734                    return Followup::Covered(format!("note: {}", title.trim()));
5735                }
5736            }
5737        }
5738        if let Some(parent) = path.parent() {
5739            let _ = std::fs::create_dir_all(parent);
5740        }
5741        use std::io::Write;
5742        // The caller already stamped the provenance into the body. Adding
5743        // "From #N." here as well printed it twice, in two different wordings.
5744        //
5745        // The marker above the heading is what makes the entry boundary
5746        // unambiguous to the parser, since the body's own sections are written
5747        // at the same heading level as the title.
5748        let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
5749        match std::fs::OpenOptions::new()
5750            .create(true)
5751            .append(true)
5752            .open(&path)
5753        {
5754            Ok(mut file) => match file.write_all(entry.as_bytes()) {
5755                Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
5756                Err(e) => {
5757                    logdim!("could not write {}: {e}", path.display());
5758                    Followup::Failed
5759                }
5760            },
5761            Err(e) => {
5762                logdim!("could not write {}: {e}", path.display());
5763                Followup::Failed
5764            }
5765        }
5766    }
5767
5768    /// Record what `spar followup` did with an entry, and why.
5769    ///
5770    /// Best effort: an archive that could not be written is not a reason to
5771    /// stop, since the entry has already been filed or ruled on.
5772    pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
5773        let path = self.worked_followups_path();
5774        if let Some(parent) = path.parent() {
5775            let _ = std::fs::create_dir_all(parent);
5776        }
5777        use std::io::Write;
5778        let entry = format!(
5779            "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
5780            title.trim(),
5781            body.trim()
5782        );
5783        if let Ok(mut file) = std::fs::OpenOptions::new()
5784            .create(true)
5785            .append(true)
5786            .open(&path)
5787        {
5788            let _ = file.write_all(entry.as_bytes());
5789        }
5790    }
5791
5792    // -- resumable state --------------------------------------------------
5793    //
5794    // Custody cannot be read from GitHub authorship: every agent commits and
5795    // comments as the same git identity, so `author` is always the human who
5796    // ran spar. State is kept on disk by default and can additionally travel in
5797    // a PR comment, which is what lets a run be resumed from another machine.
5798
5799    /// Where a comment spar produced but did not post is kept.
5800    pub fn pending_comment_path(&self, number: i64) -> PathBuf {
5801        self.root
5802            .join(STATE_DIR)
5803            .join("reviews")
5804            .join(format!("pr-{number}.md"))
5805    }
5806
5807    /// Keep a comment spar decided not to post.
5808    ///
5809    /// A dry run that prints and forgets means agreeing with what you read
5810    /// costs a second full review. Saving it makes the whole point of reading
5811    /// it first: look, edit if you like, then post what you already paid for.
5812    pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
5813        let path = self.pending_comment_path(number);
5814        if let Some(parent) = path.parent() {
5815            std::fs::create_dir_all(parent)
5816                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
5817        }
5818        std::fs::write(&path, text)
5819            .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5820        Ok(path)
5821    }
5822
5823    pub fn read_pending_comment(&self, number: i64) -> Option<String> {
5824        std::fs::read_to_string(self.pending_comment_path(number)).ok()
5825    }
5826
5827    pub fn state_path(&self, number: i64) -> PathBuf {
5828        self.root
5829            .join(STATE_DIR)
5830            .join("state")
5831            .join(format!("pr-{number}.json"))
5832    }
5833
5834    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
5835        let path = self.state_path(number);
5836        let text = std::fs::read_to_string(&path).ok()?;
5837        match serde_json::from_str(&text) {
5838            Ok(state) => Some(state),
5839            Err(_) => {
5840                logdim!("could not read {}, starting fresh", path.display());
5841                None
5842            }
5843        }
5844    }
5845
5846    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
5847        if let Some(local) = self.read_local_state(pr.number) {
5848            return Some(local);
5849        }
5850        if self.state_store.writes_pr() {
5851            return self.read_pr_state(pr.number);
5852        }
5853        None
5854    }
5855
5856    pub(crate) fn read_state_for_head(
5857        &self,
5858        pr: &PrView,
5859        actual_head: &str,
5860    ) -> Option<PersistedState> {
5861        let local = self
5862            .state_store
5863            .writes_local()
5864            .then(|| self.read_local_state(pr.number))
5865            .flatten();
5866        let remote = self
5867            .state_store
5868            .writes_pr()
5869            .then(|| self.read_pr_state(pr.number))
5870            .flatten();
5871        let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
5872        if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
5873            self.remember_checkpoint(pr.number, checkpoint);
5874        }
5875        choose_state_for_head(candidates, actual_head)
5876    }
5877
5878    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
5879        self.try_read_pr_state(number).ok().flatten()
5880    }
5881
5882    fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
5883        for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
5884            if let Some(state) = parse_state_comment(&body) {
5885                return Ok(Some(state));
5886            }
5887        }
5888        Ok(None)
5889    }
5890
5891    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5892        let remote_state = if self.state_store.writes_pr() {
5893            self.try_read_pr_state(number)
5894        } else {
5895            Ok(None)
5896        };
5897        self.write_state_after_remote_read(number, state, remote_state)
5898    }
5899
5900    fn write_state_after_remote_read(
5901        &self,
5902        number: i64,
5903        state: &PersistedState,
5904        remote_state: Result<Option<PersistedState>>,
5905    ) -> Result<()> {
5906        let remote_checkpoint = if self.state_store.writes_pr() {
5907            self.record_failed_write(remote_state)?
5908                .map(|saved| saved.checkpoint)
5909                .unwrap_or_default()
5910        } else {
5911            0
5912        };
5913        let local_checkpoint = self
5914            .state_store
5915            .writes_local()
5916            .then(|| self.read_local_state(number))
5917            .flatten()
5918            .map(|saved| saved.checkpoint)
5919            .unwrap_or_default();
5920        let mut stamped = state.clone();
5921        stamped.checkpoint = state
5922            .checkpoint
5923            .max(local_checkpoint)
5924            .max(remote_checkpoint)
5925            .max(self.remembered_checkpoint(number))
5926            .saturating_add(1);
5927        self.remember_checkpoint(number, stamped.checkpoint);
5928        if self.state_store.writes_local() {
5929            write_json_atomic(&self.state_path(number), &stamped)?;
5930        }
5931        if self.state_store.writes_pr() {
5932            self.write_pr_state(number, &stamped)?;
5933        }
5934        Ok(())
5935    }
5936
5937    fn remembered_checkpoint(&self, number: i64) -> u64 {
5938        self.checkpoints
5939            .lock()
5940            .unwrap_or_else(std::sync::PoisonError::into_inner)
5941            .get(&number)
5942            .copied()
5943            .unwrap_or_default()
5944    }
5945
5946    fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
5947        let mut checkpoints = self
5948            .checkpoints
5949            .lock()
5950            .unwrap_or_else(std::sync::PoisonError::into_inner);
5951        let saved = checkpoints.entry(number).or_default();
5952        *saved = (*saved).max(checkpoint);
5953    }
5954
5955    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5956        // Not run through clean(): this is structured data, and scrubbing would
5957        // corrupt refutation text stored in the ledger. It sits inside an
5958        // unclosed HTML comment so GitHub renders it as nothing.
5959        let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
5960        let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
5961        let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
5962        if let Some(id) = comment_id {
5963            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
5964            let field = format!("body={body}");
5965            let written = self
5966                .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
5967                .map(|_| ());
5968            return self.record_write(written);
5969        }
5970        let written = self
5971            .gh(&["pr", "comment", &number.to_string(), "--body", &body])
5972            .map(|_| ());
5973        self.record_write(written)
5974    }
5975
5976    /// Top level comments. Works for issues and pull requests alike, because
5977    /// GitHub serves both from the issues endpoint.
5978    ///
5979    /// Nothing when they cannot be read, which suits a reader that is going to
5980    /// go on regardless. A caller deciding whether it has already written here
5981    /// wants `try_issue_comments`, since for that one no comments and no answer
5982    /// are opposite answers.
5983    pub fn issue_comments(&self, number: i64) -> Vec<Value> {
5984        self.try_issue_comments(number).unwrap_or_default()
5985    }
5986
5987    pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
5988        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
5989        try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
5990    }
5991
5992    /// spar's own state comments on a pull request, newest last.
5993    ///
5994    /// Three tests, because the answer decides what gets patched over and what
5995    /// gets deleted. A comment matching the marker loosely, which is what
5996    /// somebody asking "why is there a `<!-- spar:state` block on this PR?"
5997    /// writes, is named in the log and left exactly where it is.
5998    fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
5999        let viewer = self.viewer_login()?.to_string();
6000        let mut mine = Vec::new();
6001        let mut theirs = Vec::new();
6002        for comment in self.try_issue_comments(number)? {
6003            let Some(body) = comment.get("body").and_then(Value::as_str) else {
6004                continue;
6005            };
6006            if !body.contains("spar:state") {
6007                continue;
6008            }
6009            let Some(id) = comment.get("id").and_then(Value::as_i64) else {
6010                continue;
6011            };
6012            let author = comment
6013                .get("user")
6014                .and_then(|u| u.get("login"))
6015                .and_then(Value::as_str)
6016                .unwrap_or_default();
6017            if is_state_comment(body, author, &viewer) {
6018                mine.push((id, body.to_string()));
6019            } else {
6020                theirs.push(format!("@{author}"));
6021            }
6022        }
6023        if !theirs.is_empty() {
6024            logdim!(
6025                "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
6026                 own state block is patched or deleted.",
6027                theirs.len(),
6028                theirs.join(", ")
6029            );
6030        }
6031        Ok(mine)
6032    }
6033
6034    fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
6035        Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
6036    }
6037
6038    /// Drop state once the PR is finished and there is nothing to resume.
6039    pub fn clear_state(&self, number: i64) {
6040        let path = self.state_path(number);
6041        let _ = std::fs::remove_file(&path);
6042        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
6043    }
6044
6045    // -- housekeeping -----------------------------------------------------
6046
6047    /// Remove state files whose PR is merged or closed: the custody loop's
6048    /// state, the review record, and the check-in watermark alike. Grouped by
6049    /// number so each finished pull request costs one `gh` read however many
6050    /// files it left.
6051    pub fn prune_state(&self) -> Vec<String> {
6052        let base = self.root.join(STATE_DIR).join("state");
6053        let Ok(entries) = std::fs::read_dir(&base) else {
6054            return Vec::new();
6055        };
6056        let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
6057        for name in entries
6058            .flatten()
6059            .filter_map(|e| e.file_name().to_str().map(str::to_string))
6060        {
6061            if let Some(number) = state_file_number(&name) {
6062                by_number.entry(number).or_default().push(name);
6063            }
6064        }
6065
6066        let mut removed = Vec::new();
6067        for (number, mut names) in by_number {
6068            if !is_finished(&self.pr_state(number)) {
6069                continue;
6070            }
6071            names.sort();
6072            for name in names {
6073                let _ = std::fs::remove_file(base.join(&name));
6074                removed.push(format!("state {name}"));
6075            }
6076        }
6077        removed
6078    }
6079
6080    /// Delete state comments from PRs that are finished.
6081    ///
6082    /// Open PRs are left alone: their state may still be live.
6083    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
6084        #[derive(Deserialize)]
6085        struct Row {
6086            number: i64,
6087        }
6088        let numbers = match numbers {
6089            Some(numbers) => numbers,
6090            None => {
6091                let listed: Result<Vec<i64>> = (|| {
6092                    let text = self.gh(&[
6093                        "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
6094                    ])?;
6095                    let rows = serde_json::from_str::<Vec<Row>>(text.trim())
6096                        .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
6097                    Ok(rows.into_iter().map(|row| row.number).collect())
6098                })();
6099                match self.record_failed_write(listed) {
6100                    Ok(numbers) => numbers,
6101                    Err(e) => {
6102                        logdim!("could not inspect pull requests for state cleanup: {e}");
6103                        return Vec::new();
6104                    }
6105                }
6106            }
6107        };
6108
6109        let mut removed = Vec::new();
6110        for number in numbers {
6111            let state = match self.record_failed_write(self.try_pr_state(number)) {
6112                Ok(state) => state,
6113                Err(e) => {
6114                    logdim!("could not inspect PR #{number} for state cleanup: {e}");
6115                    continue;
6116                }
6117            };
6118            if !is_finished(&state) {
6119                continue;
6120            }
6121            let comments = match self.record_failed_write(self.try_state_comments(number)) {
6122                Ok(comments) => comments,
6123                Err(e) => {
6124                    logdim!("could not inspect state comments on PR #{number}: {e}");
6125                    continue;
6126                }
6127            };
6128            for (id, _) in comments {
6129                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6130                let deleted = self
6131                    .gh(&["api", "-X", "DELETE", &path, "--silent"])
6132                    .map(|_| ());
6133                match self.record_write(deleted) {
6134                    Ok(()) => removed.push(format!("state comment on PR #{number}")),
6135                    Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
6136                }
6137            }
6138        }
6139        removed
6140    }
6141
6142    /// Drop worktrees whose PR is finished, then the branches they left behind.
6143    ///
6144    /// With auto_merge off, which is the default, a run ends at "approved", so
6145    /// nothing would ever clean these up on its own and they accumulate one per
6146    /// run. A stranded worktree also holds its branch checked out, which makes
6147    /// a later `gh pr merge --delete-branch` fail to clean up.
6148    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
6149        let base = self.root.join(WORKTREE_DIR);
6150        let mut removed = Vec::new();
6151        let known = self.known_branches();
6152
6153        if let Ok(entries) = std::fs::read_dir(&base) {
6154            let mut names: Vec<String> = entries
6155                .flatten()
6156                .filter(|e| e.path().is_dir())
6157                .filter_map(|e| e.file_name().to_str().map(str::to_string))
6158                .collect();
6159            names.sort();
6160
6161            for name in names {
6162                // A review worktree is detached and owns no branch, so it is
6163                // tied to the pull request only by its directory name.
6164                if let Some(rest) = name.strip_prefix("review-") {
6165                    let number: i64 = rest.parse().unwrap_or(-1);
6166                    if !(force_all || is_finished(&self.pr_state(number))) {
6167                        continue;
6168                    }
6169                    let path = base.join(&name);
6170                    if force_all {
6171                        let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
6172                            if !belongs {
6173                                return Ok(false);
6174                            }
6175                            let local_ref = review_ref(number);
6176                            if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
6177                                return Ok(false);
6178                            }
6179                            let head = self.head_oid_checked(&path)?;
6180                            let recorded = self
6181                                .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
6182                                .trim()
6183                                .to_string();
6184                            Ok(head == recorded)
6185                        });
6186                        match owned {
6187                            Ok(true) => {}
6188                            Ok(false) => {
6189                                logdim!(
6190                                    "kept {} because no matching SPAR review reference proves \
6191                                     ownership",
6192                                    path.display()
6193                                );
6194                                continue;
6195                            }
6196                            Err(e) => {
6197                                logdim!(
6198                                    "kept {} because review ownership could not be verified: {}",
6199                                    path.display(),
6200                                    e.last_line()
6201                                );
6202                                continue;
6203                            }
6204                        }
6205                    } else {
6206                        if let Err(e) = self.refuse_review_worktree_changes(number) {
6207                            logdim!(
6208                                "kept {} because its review state could not be verified as \
6209                                 disposable: {}",
6210                                path.display(),
6211                                e.last_line()
6212                            );
6213                            continue;
6214                        }
6215                    }
6216                    if force_all {
6217                        if self.remove_worktree_at_force(&path) {
6218                            self.git_try(&["update-ref", "-d", &review_ref(number)]);
6219                        }
6220                    } else {
6221                        self.release_review_worktree(number);
6222                    }
6223                    if !path.exists() {
6224                        removed.push(name);
6225                    }
6226                    continue;
6227                }
6228                let branch = format!("{}{name}", self.branch_prefix);
6229                if !(force_all || self.worktree_is_done(&branch)) {
6230                    continue;
6231                }
6232                if !known.contains_key(&branch) {
6233                    logdim!("kept {branch} because it has no branch record");
6234                    continue;
6235                }
6236                let path = base.join(&name);
6237                if !force_all {
6238                    match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6239                        Ok(true) => {
6240                            logdim!(
6241                                "kept {} because it contains uncommitted changes or ignored files",
6242                                path.display()
6243                            );
6244                            continue;
6245                        }
6246                        Err(e) => {
6247                            logdim!(
6248                                "kept {} because its Git state could not be checked: {}",
6249                                path.display(),
6250                                e.last_line()
6251                            );
6252                            continue;
6253                        }
6254                        Ok(false) => {}
6255                    }
6256                    match self.branch_deletion_is_safe(&branch) {
6257                        Ok(true) => {}
6258                        Ok(false) => {
6259                            logdim!(
6260                                "kept {branch} because no surviving ref preserves its tip or \
6261                                 reflog-only commits"
6262                            );
6263                            continue;
6264                        }
6265                        Err(e) => {
6266                            logdim!(
6267                                "kept {branch} because preservation could not be verified: {}",
6268                                e.last_line()
6269                            );
6270                            continue;
6271                        }
6272                    }
6273                }
6274                let removed_worktree = if force_all {
6275                    self.remove_worktree_at_force(&path)
6276                } else {
6277                    match self.remove_worktree_at(&path) {
6278                        Ok(removed) => removed,
6279                        Err(error) => {
6280                            logdim!(
6281                                "kept {branch} and {} because removal did not reach a confirmed \
6282                                 quiet point: {}",
6283                                path.display(),
6284                                error.last_line()
6285                            );
6286                            false
6287                        }
6288                    }
6289                };
6290                if !removed_worktree {
6291                    continue;
6292                }
6293                if force_all {
6294                    self.git_try(&["branch", "-D", &branch]);
6295                    self.forget_branch(&branch);
6296                } else {
6297                    match self.delete_branch_if_safe(&branch) {
6298                        Ok(true) => self.forget_branch(&branch),
6299                        Ok(false) => logdim!(
6300                            "kept {branch} because its tip or reflog changed before deletion"
6301                        ),
6302                        Err(error) => logdim!(
6303                            "kept {branch} because deletion safety could not be rechecked: {}",
6304                            error.last_line()
6305                        ),
6306                    }
6307                }
6308                removed.push(name);
6309            }
6310        }
6311        removed.extend(self.prune_branches(force_all));
6312        removed
6313    }
6314
6315    /// Delete leftover branches spar created whose worktree is already gone.
6316    ///
6317    /// Deletion is driven by the ledger of branches spar actually created, not
6318    /// by a name pattern. Names default to `issue-N`, which is exactly what a
6319    /// person would call a branch themselves, so a name alone can never
6320    /// establish ownership. This is the data loss guard.
6321    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
6322        let known = self.known_branches();
6323        let branches: Vec<String> = known.keys().cloned().collect();
6324        if branches.is_empty() {
6325            return Vec::new();
6326        }
6327
6328        let checked_out: Vec<String> = self
6329            .git_try(&["worktree", "list", "--porcelain"])
6330            .lines()
6331            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
6332            .collect();
6333
6334        // %(refname:short) is ambiguous when a tag shares the branch name (it
6335        // yields "heads/..."), so take the full ref and strip it here.
6336        let existing: Vec<String> = self
6337            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
6338            .lines()
6339            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
6340            .collect();
6341
6342        let mut removed = Vec::new();
6343        for branch in branches {
6344            if !existing.contains(&branch) {
6345                self.forget_branch(&branch); // already gone, drop the record
6346                continue;
6347            }
6348            if checked_out.contains(&branch) {
6349                continue;
6350            }
6351            if !(force_all || self.worktree_is_done(&branch)) {
6352                continue;
6353            }
6354            if !force_all {
6355                let Some(_record) = known.get(&branch) else {
6356                    continue;
6357                };
6358                match self.branch_deletion_is_safe(&branch) {
6359                    Ok(true) => {}
6360                    Ok(false) => {
6361                        logdim!(
6362                            "kept {branch} because no surviving ref preserves its tip or \
6363                             reflog-only commits"
6364                        );
6365                        continue;
6366                    }
6367                    Err(e) => {
6368                        logdim!(
6369                            "kept {branch} because preservation could not be verified: {}",
6370                            e.last_line()
6371                        );
6372                        continue;
6373                    }
6374                }
6375            }
6376            let deleted = if force_all {
6377                self.git(&["branch", "-D", &branch]).map(|_| true)
6378            } else {
6379                self.delete_branch_if_safe(&branch)
6380            };
6381            match deleted {
6382                Ok(true) => {
6383                    self.forget_branch(&branch);
6384                    removed.push(format!("branch {branch}"));
6385                }
6386                Ok(false) => {
6387                    logdim!("kept {branch} because its tip or reflog changed before deletion");
6388                }
6389                Err(e) => {
6390                    // A branch that silently survives pruning looks like a spar
6391                    // bug, so the name and git's own reason have to be said.
6392                    logdim!("could not delete {branch}: {}", e.last_line());
6393                }
6394            }
6395        }
6396        removed
6397    }
6398
6399    /// True when the PR behind this branch is merged or closed.
6400    fn worktree_is_done(&self, branch: &str) -> bool {
6401        #[derive(Deserialize)]
6402        struct Row {
6403            state: String,
6404        }
6405        let entry = branch
6406            .strip_prefix(self.branch_prefix.as_str())
6407            .unwrap_or(branch);
6408        if let Some(rest) = entry.strip_prefix("pr-") {
6409            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
6410        }
6411        // A split part is the same shape as an issue branch: one branch, whose
6412        // pull requests say whether it is finished. Without it here, a part
6413        // branch is one nothing but `clean --all` would ever remove.
6414        if entry.starts_with("issue-") || entry.starts_with("split-") {
6415            let text = self.gh_try(&[
6416                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
6417            ]);
6418            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
6419            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
6420        }
6421        false
6422    }
6423}
6424
6425// ---------------------------------------------------------------------------
6426// Free helpers
6427// ---------------------------------------------------------------------------
6428
6429/// Take a read-only probe again before treating its failure as evidence.
6430///
6431/// Every probe is a Git invocation under a thirty second cap plus a walk over a
6432/// tree an agent may still be building in. A probe that fails proves nothing
6433/// about the worktree, and there is a real difference between "the tree moved"
6434/// and "the question could not be asked just then", which the caller turns into
6435/// the same run-ending error.
6436pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
6437    match probe() {
6438        Ok(value) => Ok(value),
6439        Err(first) => {
6440            logdim!("retrying a worktree probe after: {}", first.last_line());
6441            probe()
6442        }
6443    }
6444}
6445
6446/// Read attribute files without asking Git to inspect working-tree content.
6447///
6448/// A newly written attribute can select a clean or smudge filter. It must be
6449/// detected before a post-call status, diff, or add command has a chance to run
6450/// that filter in the parent process.
6451pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
6452    let root = std::fs::canonicalize(cwd)
6453        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6454    let mut files = BTreeMap::new();
6455    let mut visited = BTreeSet::new();
6456    collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
6457    Ok(AttributeState { files })
6458}
6459
6460fn collect_attribute_files(
6461    root: &Path,
6462    repository: &Path,
6463    prefix: &Path,
6464    visited: &mut BTreeSet<PathBuf>,
6465    files: &mut BTreeMap<PathBuf, [u8; 32]>,
6466) -> Result<()> {
6467    let canonical = std::fs::canonicalize(repository)
6468        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6469    if !visited.insert(canonical) {
6470        bail!("submodule recursion revisited {}", repository.display());
6471    }
6472    let entries = index_entries(repository)?;
6473    let mut paths: BTreeSet<PathBuf> = entries
6474        .iter()
6475        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6476        .map(|entry| entry.path.clone())
6477        .collect();
6478    // Ignored on purpose. A `.gitattributes` under build output can only select
6479    // filters for paths that are themselves ignored, and no ignored path is
6480    // ever staged or compared as tracked content, so it cannot reach a managed
6481    // commit. Capturing it meant `cargo package` or `npm ci` writing one into
6482    // its own output ended a read-only call outright.
6483    paths.extend(untracked_attribute_paths(repository)?);
6484    for path in paths {
6485        let from_root = prefix.join(&path);
6486        let state = attribute_file_fingerprint(&root.join(&from_root))?;
6487        files.insert(from_root, state);
6488    }
6489    for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
6490        let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
6491            continue;
6492        };
6493        collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
6494    }
6495    Ok(())
6496}
6497
6498/// Leave a visible, untracked reason ordinary cleanup can detect on a later
6499/// run even when Git status normalizes the original working-file change away.
6500pub(crate) fn uncertain_worktree_change(
6501    cwd: &Path,
6502    message: impl Into<String>,
6503) -> crate::error::SparError {
6504    let message = message.into();
6505    let marker = write_recovery_marker(cwd, &message);
6506    let note = match marker {
6507        Ok(path) => format!(" Recovery marker: {}.", path.display()),
6508        Err(e) => format!(
6509            " A recovery marker could not be written: {}.",
6510            e.last_line()
6511        ),
6512    };
6513    crate::error::SparError::uncertain_write(format!("{message}{note}"))
6514}
6515
6516fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
6517    use std::sync::atomic::{AtomicU32, Ordering};
6518    static NEXT: AtomicU32 = AtomicU32::new(0);
6519    for _ in 0..1000 {
6520        let serial = NEXT.fetch_add(1, Ordering::Relaxed);
6521        let path = cwd.join(format!(
6522            "{RECOVERY_MARKER_PREFIX}{}-{serial}",
6523            std::process::id()
6524        ));
6525        let mut options = OpenOptions::new();
6526        options.write(true).create_new(true);
6527        #[cfg(unix)]
6528        {
6529            use std::os::unix::fs::OpenOptionsExt;
6530            options.mode(0o600);
6531        }
6532        match options.open(&path) {
6533            Ok(mut file) => {
6534                file.write_all(detail.as_bytes())
6535                    .and_then(|_| file.write_all(b"\n"))
6536                    .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6537                return Ok(path);
6538            }
6539            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
6540            Err(e) => {
6541                return Err(spar_err!(
6542                    "could not create a recovery marker in {}: {e}",
6543                    cwd.display()
6544                ))
6545            }
6546        }
6547    }
6548    bail!(
6549        "could not choose a free recovery marker name in {}",
6550        cwd.display()
6551    )
6552}
6553
6554/// Build a Git command that cannot launch automatic repository maintenance.
6555///
6556/// A fetch may otherwise prune missing linked worktree registrations. SPAR
6557/// must only remove registrations it has proven it owns.
6558fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
6559    let mut argv = vec![
6560        "git".to_string(),
6561        "-c".to_string(),
6562        "maintenance.auto=false".to_string(),
6563        "-c".to_string(),
6564        "gc.auto=0".to_string(),
6565    ];
6566    argv.extend(args.iter().map(|arg| (*arg).to_string()));
6567    argv
6568}
6569
6570fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
6571    let mut argv = git_without_maintenance_argv(&[]);
6572    argv.extend([
6573        "-c".to_string(),
6574        "core.fsmonitor=".to_string(),
6575        "-c".to_string(),
6576        "commit.gpgsign=false".to_string(),
6577        "-c".to_string(),
6578        "core.hooksPath=/dev/null".to_string(),
6579    ]);
6580    argv.extend(args.iter().map(|arg| (*arg).to_string()));
6581    argv
6582}
6583
6584/// Snapshot every untracked file, including ignored files, without changing
6585/// path bytes.
6586///
6587/// Without an exclude option, Git lists both ordinary and ignored untracked
6588/// entries. Metadata fingerprints make overwriting an existing path observable
6589/// without hashing a potentially multi-gigabyte build tree on every call.
6590pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
6591    let root = std::fs::canonicalize(cwd)
6592        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6593    let mut files = BTreeMap::new();
6594    let mut ignored = BTreeSet::new();
6595    let mut visited = BTreeSet::new();
6596    collect_untracked_files(
6597        &root,
6598        &root,
6599        Path::new(""),
6600        &mut visited,
6601        &mut files,
6602        &mut ignored,
6603    )?;
6604    Ok(IgnoredState { files, ignored })
6605}
6606
6607fn collect_untracked_files(
6608    root: &Path,
6609    repository: &Path,
6610    prefix: &Path,
6611    visited: &mut BTreeSet<PathBuf>,
6612    files: &mut BTreeMap<PathBuf, UntrackedFile>,
6613    ignored: &mut BTreeSet<PathBuf>,
6614) -> Result<()> {
6615    let canonical = std::fs::canonicalize(repository)
6616        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6617    if !visited.insert(canonical.clone()) {
6618        bail!("submodule recursion revisited {}", canonical.display());
6619    }
6620    // Classify ordinary paths in the listing itself. Listing every path here
6621    // and marking ignored paths in a second query misclassifies build output
6622    // deleted between queries as ordinary untracked work.
6623    let listed = run_git_bytes(
6624        repository,
6625        &["ls-files", "--others", "--exclude-standard", "-z"],
6626    )?;
6627    if !listed.is_empty() && !listed.ends_with(&[0]) {
6628        bail!(
6629            "git returned an unterminated untracked-file list for {}",
6630            repository.display()
6631        );
6632    }
6633
6634    for raw in listed
6635        .split(|byte| *byte == 0)
6636        .filter(|raw| !raw.is_empty())
6637    {
6638        let (relative, nested) = untracked_record(raw, "untracked")?;
6639        let from_root = prefix.join(&relative);
6640        if is_spar_own_path(&from_root) {
6641            continue;
6642        }
6643        let absolute = root.join(&from_root);
6644        let fingerprint = if nested {
6645            nested_repository_fingerprint(&absolute)?
6646        } else {
6647            ignored_file_fingerprint(&absolute)?
6648        };
6649        if files.insert(from_root.clone(), fingerprint).is_some() {
6650            bail!(
6651                "git returned the untracked path more than once: {:?}",
6652                from_root
6653            );
6654        }
6655    }
6656
6657    let ignored_listed = run_git_bytes(
6658        repository,
6659        &[
6660            "ls-files",
6661            "--others",
6662            "--ignored",
6663            "--exclude-standard",
6664            "-z",
6665        ],
6666    )?;
6667    if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
6668        bail!(
6669            "git returned an unterminated ignored-file list for {}",
6670            repository.display()
6671        );
6672    }
6673    // Two `ls-files` runs cannot be made to agree about a tree that is being
6674    // built in. A path named only by the second one was created between them,
6675    // which is a fact about the moment, not a corruption: record it and let the
6676    // comparison decide, rather than ending a run that had nothing wrong with
6677    // it.
6678    for raw in ignored_listed
6679        .split(|byte| *byte == 0)
6680        .filter(|raw| !raw.is_empty())
6681    {
6682        let (relative, nested) = untracked_record(raw, "ignored")?;
6683        let from_root = prefix.join(relative);
6684        if is_spar_own_path(&from_root) {
6685            continue;
6686        }
6687        if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
6688            let absolute = root.join(&from_root);
6689            let fingerprint = if nested {
6690                nested_repository_fingerprint(&absolute)?
6691            } else {
6692                ignored_file_fingerprint(&absolute)?
6693            };
6694            slot.insert(fingerprint);
6695        }
6696        if !ignored.insert(from_root.clone()) {
6697            bail!(
6698                "git returned the ignored path more than once: {:?}",
6699                from_root
6700            );
6701        }
6702    }
6703
6704    for link in gitlinks(repository)? {
6705        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6706            continue;
6707        };
6708        collect_untracked_files(
6709            root,
6710            &submodule,
6711            &prefix.join(&link.path),
6712            visited,
6713            files,
6714            ignored,
6715        )?;
6716    }
6717    Ok(())
6718}
6719
6720fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
6721    let argv = git_without_automation_argv(args);
6722    proc::run_bytes(
6723        &argv,
6724        &ExecOpts::new()
6725            .cwd(cwd)
6726            .timeout_secs(30)
6727            .stop_descendants(true),
6728    )
6729}
6730
6731fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
6732    let argv = git_without_automation_argv(args);
6733    proc::run(
6734        &argv,
6735        &ExecOpts::new()
6736            .cwd(cwd)
6737            .timeout_secs(30)
6738            .stop_descendants(true),
6739    )
6740}
6741
6742fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
6743    let path = path.to_str().ok_or_else(|| {
6744        spar_err!(
6745            "cannot verify filtered content for a non-UTF-8 path in {}",
6746            cwd.display()
6747        )
6748    })?;
6749    let path_arg = format!("--path={path}");
6750    let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
6751    Ok(Sha256::digest(bytes).into())
6752}
6753
6754fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
6755    let relative = path_from_git_bytes(raw)?;
6756    if relative.is_absolute()
6757        || relative.components().any(|component| {
6758            matches!(
6759                component,
6760                std::path::Component::ParentDir
6761                    | std::path::Component::RootDir
6762                    | std::path::Component::Prefix(_)
6763            )
6764        })
6765    {
6766        bail!("git returned an unsafe {kind} path: {:?}", relative);
6767    }
6768    Ok(relative)
6769}
6770
6771/// Split one `ls-files --others` record into its path and whether Git reported
6772/// a nested repository rather than a single file.
6773///
6774/// Git never lists the contents of a repository inside the working tree, so a
6775/// checkout parked there, such as another of SPAR's own worktrees, arrives as
6776/// one record for the directory itself ending in a separator. Git writes that
6777/// separator on every platform. Trimming it keeps the recorded path equal to
6778/// the same path seen any other way.
6779fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
6780    let nested = raw.last() == Some(&b'/');
6781    let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
6782    if trimmed.is_empty() {
6783        bail!("git returned an empty {kind} path");
6784    }
6785    Ok((safe_git_path(trimmed, kind)?, nested))
6786}
6787
6788fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
6789    let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
6790    if !listed.is_empty() && !listed.ends_with(&[0]) {
6791        bail!(
6792            "git returned an unterminated index listing for {}",
6793            cwd.display()
6794        );
6795    }
6796    let mut entries = Vec::new();
6797    for record in listed
6798        .split(|byte| *byte == 0)
6799        .filter(|record| !record.is_empty())
6800    {
6801        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6802            bail!(
6803                "git returned a malformed index record for {}",
6804                cwd.display()
6805            );
6806        };
6807        let header = &record[..tab];
6808        let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
6809        if fields.len() != 3 {
6810            bail!(
6811                "git returned a malformed index header for {}",
6812                cwd.display()
6813            );
6814        }
6815        if fields[2] != b"0" {
6816            continue;
6817        }
6818        let mode = std::str::from_utf8(fields[0])
6819            .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
6820            .to_string();
6821        let oid = std::str::from_utf8(fields[1])
6822            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6823            .to_string();
6824        entries.push(IndexEntry {
6825            path: safe_git_path(&record[tab + 1..], "index")?,
6826            mode,
6827            oid,
6828        });
6829    }
6830    Ok(entries)
6831}
6832
6833/// Share Git's complete ignore policy between call checkpoints and handoffs.
6834fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
6835    let listed = run_git_bytes(
6836        cwd,
6837        &[
6838            "ls-files",
6839            "--others",
6840            "--exclude-standard",
6841            "-z",
6842            "--",
6843            ".gitattributes",
6844            ":(glob)**/.gitattributes",
6845        ],
6846    )?;
6847    if !listed.is_empty() && !listed.ends_with(&[0]) {
6848        bail!(
6849            "git returned an unterminated attribute-file listing for {}",
6850            cwd.display()
6851        );
6852    }
6853    listed
6854        .split(|byte| *byte == 0)
6855        .filter(|record| !record.is_empty())
6856        .map(|raw| safe_git_path(raw, "attribute"))
6857        .collect()
6858}
6859
6860fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
6861    if !untracked_attribute_paths(cwd)?.is_empty() {
6862        return Ok(true);
6863    }
6864
6865    let index = index_entries(cwd)?
6866        .into_iter()
6867        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6868        .map(|entry| (entry.path, (entry.mode, entry.oid)))
6869        .collect::<BTreeMap<_, _>>();
6870    let head = tree_entries(cwd, "HEAD")?
6871        .into_iter()
6872        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6873        .map(|entry| (entry.path, (entry.mode, entry.oid)))
6874        .collect::<BTreeMap<_, _>>();
6875    if index != head {
6876        return Ok(true);
6877    }
6878
6879    let effective = check_attributes(cwd, index.keys().cloned())?;
6880    let config = CheckoutConfig::read(cwd)?;
6881    for (path, (_mode, oid)) in index {
6882        let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
6883            return Ok(true);
6884        };
6885        let attributes = effective
6886            .get(&path)
6887            .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
6888        if allows_expected_crlf(&config, attributes)? {
6889            if worktree.mode == "120000" {
6890                return Ok(true);
6891            }
6892            let (normalized, every_lf_was_crlf) =
6893                normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
6894            if !every_lf_was_crlf || normalized != oid {
6895                return Ok(true);
6896            }
6897        } else if worktree.raw_oid != oid {
6898            return Ok(true);
6899        }
6900    }
6901    Ok(false)
6902}
6903
6904fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
6905    Ok(index_entries(cwd)?
6906        .into_iter()
6907        .filter(|entry| entry.mode == "160000")
6908        .map(|entry| Gitlink {
6909            path: entry.path,
6910            oid: entry.oid,
6911        })
6912        .collect())
6913}
6914
6915fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
6916    let mut tracked = BTreeMap::new();
6917    for entry in index_entries(cwd)? {
6918        if entry.mode == "160000" {
6919            continue;
6920        }
6921        let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
6922        tracked.insert(
6923            entry.path,
6924            TrackedEntry {
6925                index_mode: entry.mode,
6926                index_oid: entry.oid,
6927                worktree,
6928            },
6929        );
6930    }
6931    Ok(tracked)
6932}
6933
6934fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
6935    let metadata = match std::fs::symlink_metadata(path) {
6936        Ok(metadata) => metadata,
6937        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6938        Err(e) => {
6939            return Err(spar_err!(
6940                "could not inspect tracked file {}: {e}",
6941                path.display()
6942            ))
6943        }
6944    };
6945    let mut fingerprint = Sha256::new();
6946    if metadata.file_type().is_symlink() {
6947        let target = std::fs::read_link(path)
6948            .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
6949        let bytes = os_str_bytes(target.as_os_str())?;
6950        fingerprint.update(b"symlink\0");
6951        fingerprint.update(&bytes);
6952        let content = Sha256::digest(&bytes).into();
6953        return Ok(Some(WorktreeFile {
6954            mode: "120000".to_string(),
6955            #[cfg(unix)]
6956            permissions: 0,
6957            raw_oid: git_blob_oid(oid_len, &bytes)?,
6958            fingerprint: fingerprint.finalize().into(),
6959            content,
6960        }));
6961    }
6962    if !metadata.is_file() {
6963        bail!("tracked path {} is not a file or symlink", path.display());
6964    }
6965
6966    let mut options = OpenOptions::new();
6967    options.read(true);
6968    #[cfg(unix)]
6969    {
6970        use std::os::unix::fs::OpenOptionsExt;
6971        options.custom_flags(libc::O_NOFOLLOW);
6972    }
6973    let mut file = options
6974        .open(path)
6975        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6976    let before = file
6977        .metadata()
6978        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
6979    let mode = tracked_file_mode(&before);
6980    #[cfg(unix)]
6981    let permissions = {
6982        use std::os::unix::fs::MetadataExt;
6983        before.mode() & 0o7777
6984    };
6985    fingerprint.update(b"file\0");
6986    fingerprint.update(mode.as_bytes());
6987    #[cfg(unix)]
6988    fingerprint.update(permissions.to_le_bytes());
6989    fingerprint.update(before.len().to_le_bytes());
6990    let mut content = Sha256::new();
6991    let header = format!("blob {}\0", before.len());
6992    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
6993    let mut buf = [0u8; 64 * 1024];
6994    loop {
6995        let read = file
6996            .read(&mut buf)
6997            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
6998        if read == 0 {
6999            break;
7000        }
7001        fingerprint.update(&buf[..read]);
7002        content.update(&buf[..read]);
7003        object.update(&buf[..read]);
7004    }
7005    let after = file
7006        .metadata()
7007        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7008    if before.len() != after.len()
7009        || before.modified().ok() != after.modified().ok()
7010        || before.permissions() != after.permissions()
7011    {
7012        bail!(
7013            "tracked file {} changed while it was being inspected",
7014            path.display()
7015        );
7016    }
7017    let current = std::fs::symlink_metadata(path)
7018        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7019    if !same_file(&after, &current) {
7020        bail!(
7021            "tracked file {} was replaced while it was being inspected",
7022            path.display()
7023        );
7024    }
7025    Ok(Some(WorktreeFile {
7026        mode,
7027        #[cfg(unix)]
7028        permissions,
7029        raw_oid: object.finish(),
7030        fingerprint: fingerprint.finalize().into(),
7031        content: content.finalize().into(),
7032    }))
7033}
7034
7035fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
7036    let metadata = std::fs::symlink_metadata(path)
7037        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7038    let mut digest = Sha256::new();
7039    if metadata.file_type().is_symlink() {
7040        digest.update(b"symlink\0");
7041        let target = std::fs::read_link(path)
7042            .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
7043        digest.update(os_str_bytes(target.as_os_str())?);
7044        return Ok(digest.finalize().into());
7045    }
7046    if !metadata.is_file() {
7047        bail!("attribute path {} is not a file or symlink", path.display());
7048    }
7049    let mut options = OpenOptions::new();
7050    options.read(true);
7051    #[cfg(unix)]
7052    {
7053        use std::os::unix::fs::OpenOptionsExt;
7054        options.custom_flags(libc::O_NOFOLLOW);
7055    }
7056    let mut file = options
7057        .open(path)
7058        .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7059    let before = file
7060        .metadata()
7061        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7062    digest.update(b"file\0");
7063    let mut buf = [0u8; 64 * 1024];
7064    loop {
7065        let read = file
7066            .read(&mut buf)
7067            .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7068        if read == 0 {
7069            break;
7070        }
7071        digest.update(&buf[..read]);
7072    }
7073    let after = file
7074        .metadata()
7075        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7076    let current = std::fs::symlink_metadata(path)
7077        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7078    if before.len() != after.len()
7079        || before.modified().ok() != after.modified().ok()
7080        || !same_file(&after, &current)
7081    {
7082        bail!(
7083            "attribute file {} changed while it was being inspected",
7084            path.display()
7085        );
7086    }
7087    Ok(digest.finalize().into())
7088}
7089
7090enum ObjectHasher {
7091    Sha1(Sha1),
7092    Sha256(Sha256),
7093}
7094
7095impl ObjectHasher {
7096    fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
7097        let mut hasher = match oid_len {
7098            40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
7099            64 => Self::Sha256(Sha256::new()),
7100            _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
7101        };
7102        hasher.update(header);
7103        Ok(hasher)
7104    }
7105
7106    fn update(&mut self, bytes: &[u8]) {
7107        match self {
7108            Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
7109            Self::Sha256(hasher) => hasher.update(bytes),
7110        }
7111    }
7112
7113    fn finish(self) -> String {
7114        let bytes = match self {
7115            Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
7116            Self::Sha256(hasher) => hasher.finalize().to_vec(),
7117        };
7118        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
7119    }
7120}
7121
7122fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
7123    let header = format!("blob {}\0", bytes.len());
7124    let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
7125    hasher.update(bytes);
7126    Ok(hasher.finish())
7127}
7128
7129fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
7130    let mut first = open_regular_file(path)?;
7131    let first_before = first
7132        .metadata()
7133        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7134    let mut raw_len = 0u64;
7135    let mut crlf_pairs = 0u64;
7136    let mut previous_was_cr = false;
7137    let mut every_lf_was_crlf = true;
7138    let mut buf = [0u8; 64 * 1024];
7139    loop {
7140        let read = first
7141            .read(&mut buf)
7142            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7143        if read == 0 {
7144            break;
7145        }
7146        raw_len = raw_len
7147            .checked_add(read as u64)
7148            .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
7149        for byte in &buf[..read] {
7150            if *byte == b'\n' {
7151                if previous_was_cr {
7152                    crlf_pairs += 1;
7153                } else {
7154                    every_lf_was_crlf = false;
7155                }
7156            }
7157            previous_was_cr = *byte == b'\r';
7158        }
7159    }
7160    let first_after = first
7161        .metadata()
7162        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7163    let current = std::fs::symlink_metadata(path)
7164        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7165    if raw_len != first_before.len()
7166        || !stable_file_metadata(&first_before, &first_after)
7167        || !stable_file_metadata(&first_after, &current)
7168    {
7169        bail!(
7170            "tracked file {} changed while line endings were inspected",
7171            path.display()
7172        );
7173    }
7174
7175    let normalized_len = raw_len
7176        .checked_sub(crlf_pairs)
7177        .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
7178    let header = format!("blob {normalized_len}\0");
7179    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7180    let mut second = open_regular_file(path)?;
7181    let second_before = second
7182        .metadata()
7183        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7184    if !stable_file_metadata(&first_after, &second_before) {
7185        bail!(
7186            "tracked file {} changed between line-ending checks",
7187            path.display()
7188        );
7189    }
7190    let mut pending_cr = false;
7191    loop {
7192        let read = second
7193            .read(&mut buf)
7194            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7195        if read == 0 {
7196            break;
7197        }
7198        for byte in &buf[..read] {
7199            if pending_cr {
7200                if *byte == b'\n' {
7201                    object.update(b"\n");
7202                    pending_cr = false;
7203                    continue;
7204                }
7205                object.update(b"\r");
7206                pending_cr = false;
7207            }
7208            if *byte == b'\r' {
7209                pending_cr = true;
7210            } else {
7211                object.update(std::slice::from_ref(byte));
7212            }
7213        }
7214    }
7215    if pending_cr {
7216        object.update(b"\r");
7217    }
7218    let second_after = second
7219        .metadata()
7220        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7221    let current = std::fs::symlink_metadata(path)
7222        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7223    if !stable_file_metadata(&second_before, &second_after)
7224        || !stable_file_metadata(&second_after, &current)
7225    {
7226        bail!(
7227            "tracked file {} changed while line endings were hashed",
7228            path.display()
7229        );
7230    }
7231    Ok((object.finish(), every_lf_was_crlf))
7232}
7233
7234fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7235    let mut options = OpenOptions::new();
7236    options.read(true);
7237    #[cfg(unix)]
7238    {
7239        use std::os::unix::fs::OpenOptionsExt;
7240        options.custom_flags(libc::O_NOFOLLOW);
7241    }
7242    let file = options
7243        .open(path)
7244        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7245    let metadata = file
7246        .metadata()
7247        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7248    if !metadata.is_file() {
7249        bail!("tracked path {} is not a regular file", path.display());
7250    }
7251    Ok(file)
7252}
7253
7254fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7255    if !same_file(left, right)
7256        || left.len() != right.len()
7257        || left.modified().ok() != right.modified().ok()
7258        || left.permissions() != right.permissions()
7259    {
7260        return false;
7261    }
7262    #[cfg(unix)]
7263    {
7264        use std::os::unix::fs::MetadataExt;
7265        left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
7266    }
7267    #[cfg(not(unix))]
7268    {
7269        left.created().ok() == right.created().ok()
7270    }
7271}
7272
7273/// How many paths one `check-attr` call is asked about.
7274///
7275/// `check-attr` answers with three NUL fields per attribute per path, so the
7276/// output is several times the input. A whole monorepo in one call is megabytes
7277/// in both directions, and the 30 second timeout below is a timeout on the
7278/// batch. Batching keeps each call small enough to be honest about.
7279const ATTRIBUTE_BATCH: usize = 1000;
7280
7281fn check_attributes(
7282    cwd: &Path,
7283    paths: impl IntoIterator<Item = PathBuf>,
7284) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7285    let paths = paths.into_iter().collect::<BTreeSet<_>>();
7286    if paths.is_empty() {
7287        return Ok(BTreeMap::new());
7288    }
7289    let ordered = paths.into_iter().collect::<Vec<_>>();
7290    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7291    for batch in ordered.chunks(ATTRIBUTE_BATCH) {
7292        values.extend(check_attribute_batch(cwd, batch)?);
7293    }
7294    Ok(values)
7295}
7296
7297fn check_attribute_batch(
7298    cwd: &Path,
7299    batch: &[PathBuf],
7300) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7301    const NAMES: [&str; 6] = [
7302        "filter",
7303        "working-tree-encoding",
7304        "ident",
7305        "text",
7306        "eol",
7307        "crlf",
7308    ];
7309    let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
7310    let mut input = String::new();
7311    for path in &paths {
7312        let path = path.to_str().ok_or_else(|| {
7313            spar_err!(
7314                "cannot inspect attributes for a non-UTF-8 path in {}",
7315                cwd.display()
7316            )
7317        })?;
7318        input.push_str(path);
7319        input.push('\0');
7320    }
7321    let argv = git_without_automation_argv(&[
7322        "check-attr",
7323        "-z",
7324        "--cached",
7325        "--stdin",
7326        "filter",
7327        "working-tree-encoding",
7328        "ident",
7329        "text",
7330        "eol",
7331        "crlf",
7332    ]);
7333    let output = proc::run_bytes(
7334        &argv,
7335        &ExecOpts::new()
7336            .cwd(cwd)
7337            .timeout_secs(30)
7338            .stdin(input)
7339            .stop_descendants(true),
7340    )?;
7341    if !output.is_empty() && !output.ends_with(&[0]) {
7342        bail!(
7343            "git returned an unterminated attribute result for {}",
7344            cwd.display()
7345        );
7346    }
7347    let fields = output
7348        .split(|byte| *byte == 0)
7349        .filter(|field| !field.is_empty())
7350        .collect::<Vec<_>>();
7351    if fields.len() != paths.len() * NAMES.len() * 3 {
7352        bail!(
7353            "git returned an unexpected attribute result for {}",
7354            cwd.display()
7355        );
7356    }
7357    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7358    for record in fields.chunks_exact(3) {
7359        let path = safe_git_path(record[0], "attribute")?;
7360        if !paths.contains(&path) {
7361            bail!(
7362                "git returned attributes for the wrong path in {}",
7363                cwd.display()
7364            );
7365        }
7366        let name = std::str::from_utf8(record[1])
7367            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
7368        let value = std::str::from_utf8(record[2])
7369            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
7370        values
7371            .entry(path)
7372            .or_default()
7373            .insert(name.to_string(), value.to_string());
7374    }
7375    if paths.iter().any(|path| {
7376        values
7377            .get(path)
7378            .is_none_or(|attributes| attributes.len() != NAMES.len())
7379    }) {
7380        bail!(
7381            "git omitted an attribute result for a tracked path in {}",
7382            cwd.display()
7383        );
7384    }
7385    Ok(values)
7386}
7387
7388fn attribute_is_active(value: Option<&String>) -> bool {
7389    !matches!(
7390        value.map(String::as_str),
7391        None | Some("unspecified") | Some("unset")
7392    )
7393}
7394
7395fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
7396    attribute_is_active(values.get("filter"))
7397        || attribute_is_active(values.get("working-tree-encoding"))
7398}
7399
7400/// The checkout settings a per-file line-ending decision depends on.
7401///
7402/// These are constant for a worktree, but the checks that read them run once
7403/// per tracked file, and each read was its own `git config` process. A
7404/// thousand-file repository with no `.gitattributes` takes every one of those
7405/// branches, which is half a minute of process spawning per scan, and the sweep
7406/// before a run scans every finished worktree more than once. Read them here,
7407/// once, and hand them down.
7408struct CheckoutConfig {
7409    autocrlf: Option<String>,
7410    eol: Option<String>,
7411    symlinks: Option<bool>,
7412}
7413
7414impl CheckoutConfig {
7415    fn read(cwd: &Path) -> Result<Self> {
7416        Ok(Self {
7417            autocrlf: git_config_value(cwd, "core.autocrlf")?,
7418            eol: git_config_value(cwd, "core.eol")?,
7419            symlinks: git_config_bool(cwd, "core.symlinks")?,
7420        })
7421    }
7422
7423    fn autocrlf_is_true(&self) -> bool {
7424        self.autocrlf.as_ref().is_some_and(|value| {
7425            matches!(
7426                value.to_ascii_lowercase().as_str(),
7427                "true" | "yes" | "on" | "1"
7428            )
7429        })
7430    }
7431
7432    fn eol_is(&self, wanted: &str) -> bool {
7433        self.eol
7434            .as_ref()
7435            .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
7436    }
7437}
7438
7439fn path_has_ambiguous_transform(
7440    config: &CheckoutConfig,
7441    values: &BTreeMap<String, String>,
7442) -> Result<bool> {
7443    if path_has_external_transform(values)
7444        || attribute_is_active(values.get("ident"))
7445        || attribute_is_active(values.get("crlf"))
7446    {
7447        return Ok(true);
7448    }
7449    let text = values.get("text").map(String::as_str);
7450    let eol = values.get("eol").map(String::as_str);
7451    if text == Some("auto") {
7452        return Ok(true);
7453    }
7454    if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
7455        || !matches!(
7456            eol,
7457            Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
7458        )
7459    {
7460        return Ok(true);
7461    }
7462    if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
7463        return Ok(config.autocrlf_is_true());
7464    }
7465    Ok(false)
7466}
7467
7468fn allows_expected_crlf(
7469    config: &CheckoutConfig,
7470    values: &BTreeMap<String, String>,
7471) -> Result<bool> {
7472    if path_has_external_transform(values)
7473        || attribute_is_active(values.get("ident"))
7474        || attribute_is_active(values.get("crlf"))
7475    {
7476        return Ok(false);
7477    }
7478    let text = values.get("text").map(String::as_str);
7479    let eol = values.get("eol").map(String::as_str);
7480    if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
7481        return Ok(false);
7482    }
7483    if eol == Some("crlf") {
7484        return Ok(true);
7485    }
7486    if text != Some("set") {
7487        return Ok(false);
7488    }
7489    if let Some(autocrlf) = config.autocrlf.as_deref() {
7490        match autocrlf.to_ascii_lowercase().as_str() {
7491            "true" | "yes" | "on" | "1" => return Ok(true),
7492            "input" => return Ok(false),
7493            _ => {}
7494        }
7495    }
7496    if config.eol_is("crlf") {
7497        return Ok(true);
7498    }
7499    #[cfg(windows)]
7500    if config.eol.is_none() || config.eol_is("native") {
7501        return Ok(true);
7502    }
7503    Ok(false)
7504}
7505
7506fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
7507    let argv = git_without_automation_argv(&["config", "--get", key]);
7508    let output = proc::exec(
7509        &argv,
7510        &ExecOpts::new()
7511            .cwd(cwd)
7512            .timeout_secs(30)
7513            .check(false)
7514            .stop_descendants(true),
7515    )?;
7516    match output.code {
7517        0 => Ok(Some(output.stdout.trim().to_string())),
7518        1 => Ok(None),
7519        _ => bail!(
7520            "could not read Git configuration in {}: {}",
7521            cwd.display(),
7522            output.stderr.trim()
7523        ),
7524    }
7525}
7526
7527fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
7528    let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
7529    let output = proc::exec(
7530        &argv,
7531        &ExecOpts::new()
7532            .cwd(cwd)
7533            .timeout_secs(30)
7534            .check(false)
7535            .stop_descendants(true),
7536    )?;
7537    match output.code {
7538        0 if output.stdout.trim() == "true" => Ok(Some(true)),
7539        0 if output.stdout.trim() == "false" => Ok(Some(false)),
7540        0 => bail!(
7541            "git returned an invalid boolean for {key} in {}",
7542            cwd.display()
7543        ),
7544        1 => Ok(None),
7545        _ => bail!(
7546            "could not read Git configuration in {}: {}",
7547            cwd.display(),
7548            output.stderr.trim()
7549        ),
7550    }
7551}
7552
7553#[cfg(unix)]
7554fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
7555    use std::os::unix::fs::PermissionsExt;
7556    if metadata.permissions().mode() & 0o111 == 0 {
7557        "100644".to_string()
7558    } else {
7559        "100755".to_string()
7560    }
7561}
7562
7563#[cfg(not(unix))]
7564fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
7565    "100644".to_string()
7566}
7567
7568fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
7569    let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
7570    if !listed.is_empty() && !listed.ends_with(&[0]) {
7571        bail!(
7572            "git returned an unterminated tree listing for {}",
7573            cwd.display()
7574        );
7575    }
7576    let mut entries = Vec::new();
7577    for record in listed
7578        .split(|byte| *byte == 0)
7579        .filter(|record| !record.is_empty())
7580    {
7581        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7582            bail!("git returned a malformed tree record for {}", cwd.display());
7583        };
7584        let fields = record[..tab]
7585            .split(|byte| *byte == b' ')
7586            .collect::<Vec<_>>();
7587        if fields.len() != 3 {
7588            bail!("git returned a malformed tree header for {}", cwd.display());
7589        }
7590        let mode = std::str::from_utf8(fields[0])
7591            .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
7592            .to_string();
7593        let oid = std::str::from_utf8(fields[2])
7594            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7595            .to_string();
7596        entries.push(IndexEntry {
7597            path: safe_git_path(&record[tab + 1..], "tree")?,
7598            mode,
7599            oid,
7600        });
7601    }
7602    Ok(entries)
7603}
7604
7605fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
7606    Ok(tree_entries(cwd, "HEAD")?
7607        .into_iter()
7608        .filter(|entry| entry.mode == "160000")
7609        .map(|entry| (entry.path, entry.oid))
7610        .collect())
7611}
7612
7613fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
7614    let head = head_gitlinks(cwd)?;
7615    let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
7616        .into_iter()
7617        .map(|link| (link.path, link.oid))
7618        .collect();
7619    let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
7620    paths.extend(index.keys().cloned());
7621    Ok(paths
7622        .into_iter()
7623        .filter(|path| head.get(path) != index.get(path))
7624        .collect())
7625}
7626
7627fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
7628    let path = parent.join(relative);
7629    let metadata = match std::fs::symlink_metadata(&path) {
7630        Ok(metadata) => metadata,
7631        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7632        Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7633    };
7634    if !metadata.is_dir() {
7635        bail!("the gitlink at {} is not a directory", path.display());
7636    }
7637    let canonical = std::fs::canonicalize(&path)
7638        .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
7639    if canonical != path {
7640        bail!(
7641            "the gitlink at {} resolves through a symlink",
7642            path.display()
7643        );
7644    }
7645    if !path.join(".git").exists() {
7646        let empty = std::fs::read_dir(&path)
7647            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
7648            .next()
7649            .is_none();
7650        if empty {
7651            return Ok(None);
7652        }
7653        bail!(
7654            "the uninitialized gitlink at {} contains local files",
7655            path.display()
7656        );
7657    }
7658    let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
7659    if inside.trim() != "true" {
7660        bail!("the gitlink at {} is not a worktree", path.display());
7661    }
7662    let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
7663    let top = std::fs::canonicalize(top.trim()).map_err(|e| {
7664        spar_err!(
7665            "could not resolve the gitlink top level at {}: {e}",
7666            path.display()
7667        )
7668    })?;
7669    if top != canonical {
7670        bail!(
7671            "the gitlink at {} belongs to a different worktree",
7672            path.display()
7673        );
7674    }
7675    Ok(Some(canonical))
7676}
7677
7678/// A Git entry the outer repository cannot account for.
7679///
7680/// A checkout Git itself reports as a nested repository is expected. Its
7681/// directory is recorded by identity in the untracked snapshot and keeps the
7682/// worktree from being released, so a reviewer that clones a reproduction into
7683/// ignored build output neither loses that clone nor fails the run over it.
7684/// The scan does not descend into one, because what is under it belongs to
7685/// that repository.
7686///
7687/// A Git entry Git does not report is the one worth stopping for. Running
7688/// `git init` inside a directory that already holds tracked files leaves
7689/// nothing in any listing while making every file written under it invisible
7690/// to the outer worktree, so SPAR can no longer prove what is there.
7691fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
7692    let root = std::fs::canonicalize(cwd)
7693        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7694    let mut allowed = BTreeSet::from([root.join(".git")]);
7695    let mut checkouts = BTreeSet::new();
7696    let mut repositories = vec![root.clone()];
7697    let mut visited = BTreeSet::new();
7698    while let Some(repository) = repositories.pop() {
7699        let canonical = std::fs::canonicalize(&repository)
7700            .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7701        if !visited.insert(canonical.clone()) {
7702            bail!("submodule recursion revisited {}", canonical.display());
7703        }
7704        for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
7705            let (path, nested) = untracked_record(&raw, "untracked")?;
7706            if nested {
7707                checkouts.insert(canonical.join(path));
7708            }
7709        }
7710        for link in gitlinks(&canonical)? {
7711            let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
7712                continue;
7713            };
7714            allowed.insert(submodule.join(".git"));
7715            repositories.push(submodule);
7716        }
7717    }
7718
7719    // A build deletes its own directories while this walks them. A directory
7720    // that is gone holds no Git entry, so skipping it answers the question
7721    // being asked; erroring instead ended runs whose worktrees were fine.
7722    let scan_root = root.clone();
7723    let mut directories = vec![root];
7724    while let Some(directory) = directories.pop() {
7725        let entries = match std::fs::read_dir(&directory) {
7726            Ok(entries) => entries,
7727            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7728            Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7729        };
7730        for entry in entries {
7731            let entry = match entry {
7732                Ok(entry) => entry,
7733                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7734                Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7735            };
7736            let path = entry.path();
7737            if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
7738                continue;
7739            }
7740            if entry.file_name() == OsStr::new(".git") {
7741                if !allowed.contains(&path) {
7742                    return Ok(Some(path));
7743                }
7744                continue;
7745            }
7746            let kind = match entry.file_type() {
7747                Ok(kind) => kind,
7748                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7749                Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7750            };
7751            if kind.is_dir() && !checkouts.contains(&path) {
7752                directories.push(path);
7753            }
7754        }
7755    }
7756    Ok(None)
7757}
7758
7759pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
7760    if let Some(path) = unexpected_nested_git_entry(cwd)? {
7761        bail!(
7762            "the worktree contains a Git entry at {} that Git does not report as a nested \
7763             checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
7764             prove they are unchanged.",
7765            path.display()
7766        );
7767    }
7768    let root = std::fs::canonicalize(cwd)
7769        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7770    let mut repositories = BTreeMap::new();
7771    let mut visited = BTreeSet::new();
7772    collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
7773    Ok(GitState { repositories })
7774}
7775
7776fn collect_git_state(
7777    repository: &Path,
7778    prefix: &Path,
7779    visited: &mut BTreeSet<PathBuf>,
7780    repositories: &mut BTreeMap<PathBuf, RepositoryState>,
7781) -> Result<()> {
7782    let canonical = std::fs::canonicalize(repository)
7783        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7784    if !visited.insert(canonical.clone()) {
7785        bail!("submodule recursion revisited {}", canonical.display());
7786    }
7787    let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7788    let head = head.trim().to_string();
7789    if head.is_empty() {
7790        bail!("git returned an empty head for {}", repository.display());
7791    }
7792    let unsafe_index_flags = unsafe_index_flags(repository)?;
7793    let tracked = tracked_entries(repository)?;
7794    let gitlinks = gitlinks(repository)?;
7795    if repositories
7796        .insert(
7797            prefix.to_path_buf(),
7798            RepositoryState {
7799                head,
7800                unsafe_index_flags,
7801                tracked,
7802                gitlinks: gitlinks
7803                    .iter()
7804                    .map(|link| (link.path.clone(), link.oid.clone()))
7805                    .collect(),
7806            },
7807        )
7808        .is_some()
7809    {
7810        bail!("Git state contains duplicate repository path {:?}", prefix);
7811    }
7812
7813    for link in gitlinks {
7814        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7815            continue;
7816        };
7817        collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
7818    }
7819    Ok(())
7820}
7821
7822fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
7823    let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
7824    if !listed.is_empty() && !listed.ends_with(&[0]) {
7825        bail!(
7826            "git returned an unterminated index-flag listing for {}",
7827            cwd.display()
7828        );
7829    }
7830    let mut unsafe_records = Vec::new();
7831    for record in listed
7832        .split(|byte| *byte == 0)
7833        .filter(|record| !record.is_empty())
7834    {
7835        if record.len() < 3 || record[1] != b' ' {
7836            bail!(
7837                "git returned a malformed index-flag record for {}",
7838                cwd.display()
7839            );
7840        }
7841        if record[0] != b'H' {
7842            unsafe_records.extend_from_slice(record);
7843            unsafe_records.push(0);
7844        }
7845    }
7846    Ok(unsafe_records)
7847}
7848
7849pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
7850    safe_git_state(cwd).map(|_| ())
7851}
7852
7853pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
7854    let state = git_state(cwd)?;
7855    if let Some((path, _repository)) = state
7856        .repositories
7857        .iter()
7858        .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
7859    {
7860        let label = if path.as_os_str().is_empty() {
7861            cwd.to_path_buf()
7862        } else {
7863            cwd.join(path)
7864        };
7865        bail!(
7866            "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
7867             SPAR cannot prove the working files are unchanged, so it was kept.",
7868            label.display()
7869        );
7870    }
7871    Ok(state)
7872}
7873
7874fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
7875    repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
7876}
7877
7878fn repository_has_recoverable_work_with(
7879    cwd: &Path,
7880    include_ignored: bool,
7881    disposable: Disposable,
7882) -> Result<bool> {
7883    if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
7884        return Ok(true);
7885    }
7886    let mut visited = BTreeSet::new();
7887    repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
7888}
7889
7890fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
7891    let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
7892    let git_dir = PathBuf::from(git_dir.trim());
7893    let git_dir = if git_dir.is_absolute() {
7894        git_dir
7895    } else {
7896        cwd.join(git_dir)
7897    };
7898    let git_dir = std::fs::canonicalize(&git_dir)
7899        .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
7900    match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
7901        Ok(_) => return Ok(true),
7902        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7903        Err(error) => {
7904            return Err(spar_err!(
7905                "could not inspect per-worktree configuration in {}: {error}",
7906                git_dir.display()
7907            ))
7908        }
7909    }
7910
7911    let orig_head = git_dir.join("ORIG_HEAD");
7912    match std::fs::symlink_metadata(&orig_head) {
7913        Ok(metadata) if metadata.is_file() => {
7914            let oid = std::fs::read_to_string(&orig_head)
7915                .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
7916            let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
7917                return Ok(true);
7918            };
7919            if !commit_has_shared_ref(cwd, &commit)? {
7920                return Ok(true);
7921            }
7922        }
7923        Ok(_) => return Ok(true),
7924        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7925        Err(error) => {
7926            return Err(spar_err!(
7927                "could not inspect {}: {error}",
7928                orig_head.display()
7929            ))
7930        }
7931    }
7932
7933    let edit_message = git_dir.join("COMMIT_EDITMSG");
7934    match std::fs::symlink_metadata(&edit_message) {
7935        Ok(metadata) if metadata.is_file() => {
7936            let draft = std::fs::read(&edit_message)
7937                .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
7938            if draft != head_commit_message(cwd)? {
7939                return Ok(true);
7940            }
7941        }
7942        Ok(_) => return Ok(true),
7943        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7944        Err(error) => {
7945            return Err(spar_err!(
7946                "could not inspect {}: {error}",
7947                edit_message.display()
7948            ))
7949        }
7950    }
7951
7952    if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
7953        return Ok(true);
7954    }
7955
7956    let local_refs = run_git_bytes(
7957        cwd,
7958        &[
7959            "for-each-ref",
7960            "--format=%(refname)",
7961            "refs/worktree",
7962            "refs/bisect",
7963            "refs/rewritten",
7964        ],
7965    )?;
7966    if !local_refs.is_empty() {
7967        return Ok(true);
7968    }
7969
7970    // `FETCH_HEAD` only names what a `git fetch` in this worktree brought
7971    // down. The objects live in the shared store and the next fetch rewrites
7972    // it, so an agent that fetched once must not keep the checkout forever.
7973    for entry in std::fs::read_dir(&git_dir)
7974        .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
7975    {
7976        let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
7977        let known = matches!(
7978            entry.file_name().to_str(),
7979            Some(
7980                "HEAD"
7981                    | "ORIG_HEAD"
7982                    | "COMMIT_EDITMSG"
7983                    | "FETCH_HEAD"
7984                    | "commondir"
7985                    | "gitdir"
7986                    | "index"
7987                    | "logs"
7988                    | "refs"
7989            )
7990        );
7991        if !known {
7992            return Ok(true);
7993        }
7994    }
7995
7996    let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7997    if !commit_has_shared_ref(cwd, head.trim())? {
7998        return Ok(true);
7999    }
8000    Ok(false)
8001}
8002
8003fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
8004    let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
8005    let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
8006        bail!(
8007            "git returned a commit without a message separator in {}",
8008            cwd.display()
8009        );
8010    };
8011    Ok(commit[split + 2..].to_vec())
8012}
8013
8014fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
8015    let metadata = match std::fs::symlink_metadata(logs) {
8016        Ok(metadata) => metadata,
8017        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
8018        Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
8019    };
8020    if !metadata.is_dir() {
8021        return Ok(true);
8022    }
8023    let mut files = Vec::new();
8024    let mut directories = vec![logs.to_path_buf()];
8025    while let Some(directory) = directories.pop() {
8026        for entry in std::fs::read_dir(&directory)
8027            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
8028        {
8029            let entry =
8030                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
8031            let path = entry.path();
8032            let kind = entry
8033                .file_type()
8034                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
8035            if kind.is_dir() {
8036                directories.push(path);
8037            } else if kind.is_file() {
8038                files.push(path);
8039            } else {
8040                return Ok(true);
8041            }
8042        }
8043    }
8044
8045    let mut commits = BTreeSet::new();
8046    for path in files {
8047        if !collect_reflog_commits(cwd, &path, &mut commits)? {
8048            return Ok(true);
8049        }
8050    }
8051    for commit in commits {
8052        if !commit_has_shared_ref(cwd, &commit)? {
8053            return Ok(true);
8054        }
8055    }
8056    Ok(false)
8057}
8058
8059/// Every commit named by one ref's common reflog must survive deletion of that
8060/// ref. Ancestors of a durable current tip survive with the tip; divergent
8061/// entries need another shared ref of their own.
8062fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
8063    let common = common_git_dir(cwd)?;
8064    let reflog = common.join("logs").join(refname);
8065    let metadata = match std::fs::symlink_metadata(&reflog) {
8066        Ok(metadata) => metadata,
8067        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
8068        Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
8069    };
8070    if !metadata.is_file() {
8071        return Ok(false);
8072    }
8073    let mut commits = BTreeSet::new();
8074    if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
8075        return Ok(false);
8076    }
8077    for commit in commits {
8078        if is_ancestor(cwd, &commit, durable_tip)?
8079            || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
8080        {
8081            continue;
8082        }
8083        return Ok(false);
8084    }
8085    Ok(true)
8086}
8087
8088fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
8089    let file = std::fs::File::open(path)
8090        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8091    for line in std::io::BufReader::new(file).lines() {
8092        let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8093        let mut fields = line.splitn(3, ' ');
8094        let Some(old) = fields.next() else {
8095            return Ok(false);
8096        };
8097        let Some(new) = fields.next() else {
8098            return Ok(false);
8099        };
8100        if fields.next().is_none() {
8101            return Ok(false);
8102        }
8103        for oid in [old, new] {
8104            if oid.bytes().all(|byte| byte == b'0') {
8105                continue;
8106            }
8107            let Some(commit) = resolve_optional_commit(cwd, oid)? else {
8108                return Ok(false);
8109            };
8110            commits.insert(commit);
8111        }
8112    }
8113    Ok(true)
8114}
8115
8116fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
8117    let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
8118    let path = PathBuf::from(raw.trim());
8119    let path = if path.is_absolute() {
8120        path
8121    } else {
8122        cwd.join(path)
8123    };
8124    std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
8125}
8126
8127fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
8128    let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
8129    let output = proc::exec(
8130        &argv,
8131        &ExecOpts::new()
8132            .cwd(cwd)
8133            .timeout_secs(30)
8134            .check(false)
8135            .stop_descendants(true),
8136    )?;
8137    match output.code {
8138        0 => Ok(true),
8139        1 => Ok(false),
8140        _ => bail!("{}", proc::failure_message(&argv, &output)),
8141    }
8142}
8143
8144fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
8145    let commit = format!("{oid}^{{commit}}");
8146    let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
8147    let output = proc::exec(
8148        &argv,
8149        &ExecOpts::new()
8150            .cwd(cwd)
8151            .timeout_secs(30)
8152            .check(false)
8153            .stop_descendants(true),
8154    )?;
8155    if output.code != 0 {
8156        return Ok(None);
8157    }
8158    let oid = output.stdout.trim();
8159    if oid.is_empty() {
8160        return Ok(None);
8161    }
8162    Ok(Some(oid.to_string()))
8163}
8164
8165fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
8166    commit_has_shared_ref_except(cwd, oid, None)
8167}
8168
8169fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
8170    let contains = format!("--contains={oid}");
8171    let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
8172    Ok(shared.split(|byte| *byte == b'\n').any(|record| {
8173        !record.is_empty()
8174            && !record.starts_with(b"refs/worktree/")
8175            && !record.starts_with(b"refs/bisect/")
8176            && !record.starts_with(b"refs/rewritten/")
8177            && exclude.is_none_or(|excluded| record != excluded.as_bytes())
8178    }))
8179}
8180
8181/// Whether removing the worktree would take away an untracked file somebody
8182/// might want back.
8183///
8184/// Ordinary untracked files always count. So does an ignored file outside the
8185/// known build and cache directories, because an ignored path is only a path
8186/// Git was told not to track, which is where a local `.env` lives as readily as
8187/// compiler output.
8188///
8189/// Recognized build and cache output does not. A managed commit already leaves
8190/// it out rather than treating it as work, and the command that wrote it writes
8191/// it again. Counting it kept every worktree whose tests or build had run,
8192/// which is nearly all of them, so a merged pull request still left its
8193/// checkout behind. A repository nested in that output is somebody else's
8194/// history and counts whatever it sits under.
8195fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
8196    let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
8197    for raw in &ordinary {
8198        let (path, _) = untracked_record(raw, "untracked")?;
8199        if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
8200            continue;
8201        }
8202        return Ok(true);
8203    }
8204    let listed = untracked_listing(
8205        cwd,
8206        &[
8207            "ls-files",
8208            "--others",
8209            "--ignored",
8210            "--exclude-standard",
8211            "-z",
8212        ],
8213    )?;
8214    for raw in listed {
8215        let (path, nested) = untracked_record(&raw, "ignored")?;
8216        // A repository cloned into the output keeps the checkout whatever the
8217        // mode: its objects exist nowhere else, which is the one thing a
8218        // rebuild from origin cannot replace.
8219        if nested {
8220            return Ok(true);
8221        }
8222        if disposable == Disposable::BuildOutput
8223            && !is_generated_artifact(&path)
8224            && !is_desktop_metadata(&path)
8225        {
8226            return Ok(true);
8227        }
8228    }
8229    Ok(false)
8230}
8231
8232fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8233    let listed = run_git_bytes(cwd, args)?;
8234    if !listed.is_empty() && !listed.ends_with(&[0]) {
8235        bail!(
8236            "git returned an unterminated untracked-file list for {}",
8237            cwd.display()
8238        );
8239    }
8240    Ok(listed
8241        .split(|byte| *byte == 0)
8242        .filter(|raw| !raw.is_empty())
8243        .map(|raw| raw.to_vec())
8244        .collect())
8245}
8246
8247fn repository_has_recoverable_work_inner(
8248    cwd: &Path,
8249    include_ignored: bool,
8250    disposable: Disposable,
8251    visited: &mut BTreeSet<PathBuf>,
8252) -> Result<bool> {
8253    let canonical = std::fs::canonicalize(cwd)
8254        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8255    if !visited.insert(canonical.clone()) {
8256        bail!("submodule recursion revisited {}", canonical.display());
8257    }
8258    if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
8259        return Ok(true);
8260    }
8261    if !unsafe_index_flags(cwd)?.is_empty() {
8262        return Ok(true);
8263    }
8264    if attributes_may_be_modified(cwd)? {
8265        return Ok(true);
8266    }
8267    if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
8268        return Ok(true);
8269    }
8270    if include_ignored {
8271        let index = index_entries(cwd)?
8272            .into_iter()
8273            .map(|entry| (entry.path, (entry.mode, entry.oid)))
8274            .collect::<BTreeMap<_, _>>();
8275        let head = tree_entries(cwd, "HEAD")?
8276            .into_iter()
8277            .map(|entry| (entry.path, (entry.mode, entry.oid)))
8278            .collect::<BTreeMap<_, _>>();
8279        if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
8280            return Ok(true);
8281        }
8282        let tracked = tracked_entries(cwd)?;
8283        let effective = check_attributes(cwd, tracked.keys().cloned())?;
8284        let config = CheckoutConfig::read(cwd)?;
8285        for (path, entry) in tracked {
8286            let Some(worktree) = entry.worktree else {
8287                return Ok(true);
8288            };
8289            let attributes = effective.get(&path).ok_or_else(|| {
8290                spar_err!("git omitted attributes for {}", cwd.join(&path).display())
8291            })?;
8292            if path_has_ambiguous_transform(&config, attributes)? {
8293                return Ok(true);
8294            }
8295            let symlink_file = entry.index_mode == "120000"
8296                && worktree.mode == "100644"
8297                && worktree.raw_oid == entry.index_oid
8298                && config.symlinks == Some(false);
8299            if worktree.mode != entry.index_mode && !symlink_file {
8300                return Ok(true);
8301            }
8302            if entry.index_mode == "120000" {
8303                if worktree.raw_oid != entry.index_oid {
8304                    return Ok(true);
8305                }
8306                continue;
8307            }
8308            #[cfg(unix)]
8309            {
8310                let expected = if entry.index_mode == "100755" {
8311                    0o755
8312                } else {
8313                    0o644
8314                };
8315                if worktree.permissions != expected {
8316                    return Ok(true);
8317                }
8318            }
8319            if allows_expected_crlf(&config, attributes)? {
8320                let (normalized, every_lf_was_crlf) =
8321                    normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
8322                if !every_lf_was_crlf || normalized != entry.index_oid {
8323                    return Ok(true);
8324                }
8325            } else if worktree.raw_oid != entry.index_oid {
8326                return Ok(true);
8327            }
8328        }
8329    } else {
8330        let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
8331        if !run_git_bytes(cwd, &args)?.is_empty() {
8332            return Ok(true);
8333        }
8334    }
8335    for link in gitlinks(cwd)? {
8336        let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
8337            continue;
8338        };
8339        // Git stores a linked worktree's initialized submodule objects under
8340        // that worktree's administrative directory. Ordinary removal cannot
8341        // prove a local submodule commit exists anywhere else, even when both
8342        // working trees look clean.
8343        if include_ignored {
8344            return Ok(true);
8345        }
8346        let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8347        if head.trim() != link.oid {
8348            return Ok(true);
8349        }
8350        if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
8351        {
8352            return Ok(true);
8353        }
8354    }
8355    Ok(false)
8356}
8357
8358pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
8359    repository_has_recoverable_work(cwd, false)
8360}
8361
8362fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
8363    let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
8364    Ok(!run_git_bytes(cwd, &args)?.is_empty())
8365}
8366
8367#[cfg(unix)]
8368fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8369    use std::os::unix::ffi::OsStringExt;
8370    Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
8371}
8372
8373#[cfg(not(unix))]
8374fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8375    String::from_utf8(raw.to_vec())
8376        .map(PathBuf::from)
8377        .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
8378}
8379
8380/// Fingerprint the directory of a nested repository without reading inside it.
8381///
8382/// The files under it are that repository's, not this one's. They are recorded
8383/// against its own baseline whenever SPAR works there, and a run of its own may
8384/// legitimately add or remove entries while this call is in flight, so the
8385/// volatile directory fields stay out of the fingerprint. Identity and type
8386/// remain, which is what makes deleting the checkout, or replacing it with a
8387/// file, observable from the outer worktree.
8388/// What a path Git listed looks like once it is no longer there.
8389///
8390/// A reviewer building the project deletes and rewrites its output constantly,
8391/// so a path can go between the listing that named it and the stat that
8392/// measures it. Treating that as an unreadable worktree ended runs that had
8393/// nothing wrong with them. The record stays in the snapshot rather than being
8394/// dropped, so a path that vanishes cannot pass for one that was never there.
8395fn vanished_untracked_file() -> UntrackedFile {
8396    UntrackedFile {
8397        kind: 0,
8398        len: 0,
8399        modified: None,
8400        created: None,
8401        readonly: false,
8402        symlink_target: None,
8403        #[cfg(unix)]
8404        device: 0,
8405        #[cfg(unix)]
8406        inode: 0,
8407        #[cfg(unix)]
8408        mode: 0,
8409        #[cfg(unix)]
8410        change_seconds: 0,
8411        #[cfg(unix)]
8412        change_nanoseconds: 0,
8413    }
8414}
8415
8416fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
8417    let metadata = match std::fs::symlink_metadata(path) {
8418        Ok(metadata) => metadata,
8419        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8420        Err(e) => {
8421            return Err(spar_err!(
8422                "could not inspect the nested repository at {}: {e}",
8423                path.display()
8424            ))
8425        }
8426    };
8427    if !metadata.is_dir() {
8428        bail!(
8429            "git reported {} as a nested repository, but it is not a directory",
8430            path.display()
8431        );
8432    }
8433    if std::fs::symlink_metadata(path.join(".git")).is_err() {
8434        bail!(
8435            "git reported {} as a nested repository, but it has no Git entry",
8436            path.display()
8437        );
8438    }
8439    #[cfg(unix)]
8440    {
8441        use std::os::unix::fs::MetadataExt;
8442        Ok(UntrackedFile {
8443            kind: 3,
8444            len: 0,
8445            modified: None,
8446            created: metadata.created().ok(),
8447            readonly: metadata.permissions().readonly(),
8448            symlink_target: None,
8449            device: metadata.dev(),
8450            inode: metadata.ino(),
8451            mode: metadata.mode(),
8452            change_seconds: 0,
8453            change_nanoseconds: 0,
8454        })
8455    }
8456    #[cfg(not(unix))]
8457    {
8458        Ok(UntrackedFile {
8459            kind: 3,
8460            len: 0,
8461            modified: None,
8462            created: metadata.created().ok(),
8463            readonly: metadata.permissions().readonly(),
8464            symlink_target: None,
8465        })
8466    }
8467}
8468
8469fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
8470    let metadata = match std::fs::symlink_metadata(path) {
8471        Ok(metadata) => metadata,
8472        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8473        Err(e) => {
8474            return Err(spar_err!(
8475                "could not inspect untracked file {}: {e}",
8476                path.display()
8477            ))
8478        }
8479    };
8480    let kind = if metadata.file_type().is_symlink() {
8481        2
8482    } else if metadata.is_file() {
8483        1
8484    } else {
8485        bail!(
8486            "untracked path {} is not a regular file or symlink",
8487            path.display()
8488        );
8489    };
8490    let symlink_target = if kind == 2 {
8491        match std::fs::read_link(path) {
8492            Ok(target) => Some(os_str_bytes(target.as_os_str())?),
8493            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
8494                return Ok(vanished_untracked_file())
8495            }
8496            Err(e) => {
8497                return Err(spar_err!(
8498                    "could not read untracked symlink {}: {e}",
8499                    path.display()
8500                ))
8501            }
8502        }
8503    } else {
8504        None
8505    };
8506    #[cfg(unix)]
8507    {
8508        use std::os::unix::fs::MetadataExt;
8509        Ok(UntrackedFile {
8510            kind,
8511            len: metadata.len(),
8512            modified: metadata.modified().ok(),
8513            created: metadata.created().ok(),
8514            readonly: metadata.permissions().readonly(),
8515            symlink_target,
8516            device: metadata.dev(),
8517            inode: metadata.ino(),
8518            mode: metadata.mode(),
8519            change_seconds: metadata.ctime(),
8520            change_nanoseconds: metadata.ctime_nsec(),
8521        })
8522    }
8523    #[cfg(not(unix))]
8524    {
8525        Ok(UntrackedFile {
8526            kind,
8527            len: metadata.len(),
8528            modified: metadata.modified().ok(),
8529            created: metadata.created().ok(),
8530            readonly: metadata.permissions().readonly(),
8531            symlink_target,
8532        })
8533    }
8534}
8535
8536#[cfg(unix)]
8537fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8538    use std::os::unix::ffi::OsStrExt;
8539    Ok(value.as_bytes().to_vec())
8540}
8541
8542#[cfg(not(unix))]
8543fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8544    value
8545        .to_str()
8546        .map(|value| value.as_bytes().to_vec())
8547        .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
8548}
8549
8550#[cfg(unix)]
8551fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8552    use std::os::unix::fs::MetadataExt;
8553    right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
8554}
8555
8556#[cfg(not(unix))]
8557fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8558    right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
8559}
8560
8561#[derive(Debug, Clone, serde::Serialize, Deserialize)]
8562pub struct BranchRecord {
8563    pub kind: String,
8564    pub number: i64,
8565}
8566
8567/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
8568/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
8569pub fn review_ref(number: i64) -> String {
8570    format!("refs/spar/pr-{number}")
8571}
8572
8573pub fn is_finished(state: &str) -> bool {
8574    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
8575}
8576
8577/// Write text through a temporary file and rename, so a kill cannot leave a
8578/// truncated file behind.
8579///
8580/// The follow-up queue is the one file spar rewrites in place rather than
8581/// appends to, and a truncated queue is lost work: what it held was never
8582/// written anywhere else.
8583pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
8584    if let Some(parent) = path.parent() {
8585        std::fs::create_dir_all(parent)
8586            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
8587    }
8588    // The extension defaults to `json` so `clear_state`, which removes a
8589    // leftover `pr-N.json.tmp` by name, keeps finding the one this wrote.
8590    let tmp = path.with_extension(format!(
8591        "{}.tmp",
8592        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
8593    ));
8594    std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
8595    std::fs::rename(&tmp, path)
8596        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
8597    Ok(())
8598}
8599
8600/// Whether a follow-up file already has an entry under this exact title.
8601///
8602/// Whole heading lines, case insensitively. A substring check answered yes
8603/// for "Retry loop" against "## Retry loop never terminates", which is a
8604/// different defect, and the new one was dropped with a line saying it was
8605/// already noted.
8606pub fn has_heading(text: &str, title: &str) -> bool {
8607    let wanted = title.trim().to_lowercase();
8608    if wanted.is_empty() {
8609        return false;
8610    }
8611    text.lines()
8612        .filter_map(|line| line.trim().strip_prefix("## "))
8613        .any(|heading| heading.trim().to_lowercase() == wanted)
8614}
8615
8616/// Write JSON through a temporary file and rename, so a kill cannot leave a
8617/// truncated state file behind.
8618pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
8619    write_text_atomic(path, &serde_json::to_string_pretty(value)?)
8620}
8621
8622/// One row of `gh pr list --json number,url,title,baseRefName,closingIssuesReferences`.
8623#[derive(Deserialize)]
8624#[serde(rename_all = "camelCase")]
8625pub struct LinkedPrRow {
8626    number: i64,
8627    #[serde(default)]
8628    url: String,
8629    #[serde(default)]
8630    title: String,
8631    #[serde(default)]
8632    closing_issues_references: Vec<IssueRef>,
8633}
8634
8635/// The listed pull requests, or `None` when the payload could not be read.
8636///
8637/// Separated from the gh call so the real payload shape can be tested, and kept
8638/// distinct from an empty list, because silently failing to parse would look
8639/// exactly like "no pull request exists", which is the answer that makes spar
8640/// implement over the top of somebody's work.
8641pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
8642    serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
8643}
8644
8645/// Among the listed pull requests, the first that would close `issue`.
8646pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
8647    rows.iter()
8648        .find(|row| {
8649            row.closing_issues_references
8650                .iter()
8651                .any(|linked| linked.number == issue)
8652        })
8653        .map(|row| PrRef {
8654            number: row.number,
8655            url: row.url.clone(),
8656            title: row.title.clone(),
8657        })
8658}
8659
8660/// Both halves together, for the tests that hold the real payload.
8661pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
8662    pick_linked_pr(&linked_pr_rows(json)?, issue)
8663}
8664
8665/// Flatten whatever `gh api --paginate` printed into a list of comments.
8666///
8667/// Current gh merges array pages into one array. Older builds concatenated one
8668/// document per page. A streaming parser reads either, and unlike splitting the
8669/// text on a bracket pair it cannot be fooled by a comment body that happens to
8670/// contain one, which would otherwise make a resume silently start over.
8671pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
8672    if text.trim().is_empty() {
8673        return Err(spar_err!("GitHub returned no comment data"));
8674    }
8675    let mut out = Vec::new();
8676    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8677        match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
8678            Value::Array(items) => out.extend(items),
8679            _ => return Err(spar_err!("unexpected non-array comment page")),
8680        }
8681    }
8682    Ok(out)
8683}
8684
8685pub fn parse_comment_pages(text: &str) -> Vec<Value> {
8686    let mut out = Vec::new();
8687    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8688        match value {
8689            Ok(Value::Array(items)) => out.extend(items),
8690            Ok(other) => out.push(other),
8691            Err(_) => break,
8692        }
8693    }
8694    out
8695}
8696
8697/// Whether a comment is a state block spar itself wrote.
8698///
8699/// The body has to open with the marker, so a person quoting it in a sentence
8700/// is not one; it has to parse, so a half-quoted block is not one; and it has
8701/// to be the viewer's own comment, the way `comments.rs` already tells spar's
8702/// writes apart. Anything less and `write_pr_state` patches somebody's comment
8703/// with JSON, and `spar clean --pr-state` later deletes it.
8704pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
8705    body.trim_start().starts_with(STATE_MARKER)
8706        && !viewer.trim().is_empty()
8707        && author.trim().eq_ignore_ascii_case(viewer.trim())
8708        && parse_state_comment(body).is_some()
8709}
8710
8711/// Extract the payload from a state comment. The marker is followed by JSON and
8712/// terminated with `-->`.
8713pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
8714    let marker = body.find(STATE_MARKER)?;
8715    let start = body[marker..].find('{')? + marker;
8716    let end = body.rfind('}')?;
8717    if end <= start {
8718        return None;
8719    }
8720    match serde_json::from_str(&body[start..=end]) {
8721        Ok(state) => Some(state),
8722        Err(_) => {
8723            logdim!("found a spar state comment but could not parse it");
8724            None
8725        }
8726    }
8727}
8728
8729fn choose_state_for_head(
8730    candidates: Vec<PersistedState>,
8731    actual_head: &str,
8732) -> Option<PersistedState> {
8733    let matching: Vec<PersistedState> = candidates
8734        .iter()
8735        .filter(|state| state.pr_head == actual_head)
8736        .cloned()
8737        .collect();
8738    if !matching.is_empty() {
8739        return newest_state(matching);
8740    }
8741    newest_state(candidates)
8742}
8743
8744fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
8745    candidates.into_iter().reduce(|best, candidate| {
8746        if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
8747            candidate
8748        } else {
8749            // The local candidate is supplied first. Keeping the first exact
8750            // tie recovers correctly from a local write followed by a failed
8751            // pull request state update, including legacy states with no
8752            // checkpoint field.
8753            best
8754        }
8755    })
8756}
8757
8758/// Where this binary lives, so `git filter-branch` can call back into it.
8759///
8760/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
8761/// tests, whose `current_exe` is the test harness rather than spar, and for
8762/// anyone who ships spar behind a wrapper script.
8763pub fn self_binary() -> Result<PathBuf> {
8764    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
8765        let path = PathBuf::from(path);
8766        if proc::is_executable(&path) {
8767            return Ok(path);
8768        }
8769        bail!(
8770            "SPAR_SELF_BIN is set to {}, which is not executable",
8771            path.display()
8772        );
8773    }
8774    std::env::current_exe()
8775        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
8776}
8777
8778fn bool_env(value: bool) -> &'static str {
8779    if value {
8780        "1"
8781    } else {
8782        "0"
8783    }
8784}
8785
8786/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
8787/// shell command, and an install path with a space in it is not exotic.
8788pub fn sh_quote(text: &str) -> String {
8789    format!("'{}'", text.replace('\'', r"'\''"))
8790}
8791
8792/// Style rules for the `scrub-filter` subcommand, which runs in a child process
8793/// spawned by git and so cannot see the parent's config.
8794pub fn style_from_env() -> Style {
8795    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
8796    Style {
8797        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
8798        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
8799        ..Style::permissive()
8800    }
8801}
8802
8803#[cfg(test)]
8804mod tests {
8805    use super::*;
8806    use crate::config::StateStore;
8807    use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
8808    use std::process::Command;
8809
8810    fn repo_for_titles() -> Repo {
8811        Repo {
8812            root: PathBuf::from("/nonexistent"),
8813            style: Style::default(),
8814            branch_prefix: String::new(),
8815            state_store: StateStore::Local,
8816            followups: crate::config::Followups::Issues,
8817            drafts: Drafts::Never,
8818            fork_when_denied: true,
8819            fork: OnceLock::new(),
8820            viewer: OnceLock::new(),
8821            checkpoints: Mutex::new(BTreeMap::new()),
8822            writes: WriteStats::default(),
8823        }
8824    }
8825
8826    #[test]
8827    fn write_results_accumulate_for_the_run() {
8828        let repo = repo_for_titles();
8829
8830        let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
8831        let _: std::result::Result<(), ()> = repo.record_write(Err(()));
8832
8833        assert_eq!(
8834            WriteSummary {
8835                attempted: 2,
8836                failed: 1,
8837            },
8838            repo.write_summary()
8839        );
8840    }
8841
8842    #[test]
8843    fn only_failed_write_preflights_join_the_summary() {
8844        let repo = repo_for_titles();
8845
8846        let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
8847        let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
8848
8849        assert_eq!(
8850            WriteSummary {
8851                attempted: 1,
8852                failed: 1,
8853            },
8854            repo.write_summary()
8855        );
8856    }
8857
8858    #[test]
8859    fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
8860        let repo = repo_for_titles();
8861
8862        assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
8863        assert_eq!(
8864            WriteSummary {
8865                attempted: 1,
8866                failed: 1,
8867            },
8868            repo.write_summary()
8869        );
8870    }
8871
8872    #[test]
8873    fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
8874        let mut repo = repo_for_titles();
8875        repo.followups = Followups::Local;
8876
8877        assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
8878        assert_eq!(WriteSummary::default(), repo.write_summary());
8879    }
8880
8881    #[test]
8882    fn a_failed_remote_state_read_stops_before_state_mutation() {
8883        let root = std::env::temp_dir().join(format!(
8884            "spar-state-preflight-{}-{}",
8885            std::process::id(),
8886            std::time::SystemTime::now()
8887                .duration_since(std::time::UNIX_EPOCH)
8888                .unwrap()
8889                .as_nanos()
8890        ));
8891        std::fs::create_dir_all(&root).unwrap();
8892        let _fixture = ReviewFixture { root: root.clone() };
8893        let mut repo = repo_for_titles();
8894        repo.root = root;
8895        repo.state_store = StateStore::Both;
8896        let state = PersistedState {
8897            version: 1,
8898            checkpoint: 4,
8899            round: 2,
8900            next_actor: "a".into(),
8901            status: Status::Pending,
8902            pr_head: "abc123".into(),
8903            ledger: Ledger::new(),
8904            filed: Vec::new(),
8905            open_findings: Vec::new(),
8906            disputes: Vec::new(),
8907            noted: Vec::new(),
8908        };
8909
8910        let error = repo
8911            .write_state_after_remote_read(
8912                7,
8913                &state,
8914                Err(crate::error::SparError::new("state comments unavailable")),
8915            )
8916            .unwrap_err();
8917
8918        assert!(error.to_string().contains("state comments unavailable"));
8919        assert!(!repo.state_path(7).exists());
8920        assert_eq!(0, repo.remembered_checkpoint(7));
8921        assert_eq!(
8922            WriteSummary {
8923                attempted: 1,
8924                failed: 1,
8925            },
8926            repo.write_summary()
8927        );
8928    }
8929
8930    #[test]
8931    fn only_known_build_and_cache_directories_are_generated_artifacts() {
8932        assert!(is_generated_artifact(Path::new("target/debug/artifact")));
8933        assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
8934        assert!(is_generated_artifact(Path::new(
8935            "package/node_modules/dependency/file.js"
8936        )));
8937        assert!(!is_generated_artifact(Path::new(
8938            "distribution/required-package.js"
8939        )));
8940        assert!(!is_generated_artifact(Path::new(
8941            "generated/required-fixture.txt"
8942        )));
8943        assert!(!is_generated_artifact(Path::new("local.env")));
8944    }
8945
8946    #[test]
8947    fn only_finder_state_files_are_desktop_metadata() {
8948        assert!(is_desktop_metadata(Path::new(".DS_Store")));
8949        assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
8950        assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
8951        assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
8952    }
8953
8954    struct ReviewFixture {
8955        root: PathBuf,
8956    }
8957
8958    impl Drop for ReviewFixture {
8959        fn drop(&mut self) {
8960            let _ = std::fs::remove_dir_all(&self.root);
8961        }
8962    }
8963
8964    fn test_git(cwd: &Path, args: &[&str]) -> String {
8965        let output = Command::new("git")
8966            .args(args)
8967            .current_dir(cwd)
8968            .output()
8969            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
8970        assert!(
8971            output.status.success(),
8972            "git {args:?} failed: {}",
8973            String::from_utf8_lossy(&output.stderr)
8974        );
8975        String::from_utf8_lossy(&output.stdout).into_owned()
8976    }
8977
8978    /// The diff a line comment is checked against is parsed from git's output,
8979    /// so a user setting that changes that output must not change the answer,
8980    /// and a text converter the branch could have changed must never run.
8981    #[test]
8982    fn the_lines_a_comment_can_go_on_ignore_how_the_user_has_git_print_a_diff() {
8983        let root =
8984            std::env::temp_dir().join(format!("spar-repo-test-hunks-{}", std::process::id()));
8985        let _cleanup = ReviewFixture { root: root.clone() };
8986        let origin = root.join("origin.git");
8987        let work = root.join("work");
8988        let marker = root.join("textconv-ran");
8989        std::fs::create_dir_all(&origin).unwrap();
8990        std::fs::create_dir_all(&work).unwrap();
8991        test_git(&origin, &["init", "--bare", "-b", "main"]);
8992        test_git(&work, &["init", "-b", "main"]);
8993        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
8994        test_git(&work, &["config", "user.name", "spar test"]);
8995        test_git(&work, &["config", "commit.gpgsign", "false"]);
8996        test_git(&work, &["config", "diff.noprefix", "true"]);
8997        test_git(&work, &["config", "diff.context", "0"]);
8998        test_git(&work, &["config", "diff.interHunkContext", "10"]);
8999        test_git(&work, &["config", "diff.algorithm", "patience"]);
9000        test_git(&work, &["config", "color.diff", "always"]);
9001        test_git(
9002            &work,
9003            &[
9004                "config",
9005                "diff.conv.textconv",
9006                &format!("touch {}; cat", marker.display()),
9007            ],
9008        );
9009        std::fs::write(work.join(".git/info/attributes"), "*.txt diff=conv\n").unwrap();
9010        let lines: String = (1..=20).map(|n| format!("line {n}\n")).collect();
9011        std::fs::write(work.join("src.txt"), &lines).unwrap();
9012        test_git(&work, &["add", "."]);
9013        test_git(&work, &["commit", "-m", "seed"]);
9014        test_git(
9015            &work,
9016            &["remote", "add", "origin", origin.to_str().unwrap()],
9017        );
9018        test_git(&work, &["push", "-u", "origin", "main"]);
9019        test_git(&work, &["checkout", "-b", "feature"]);
9020        std::fs::write(
9021            work.join("src.txt"),
9022            lines
9023                .replace("line 5\n", "changed 5\n")
9024                .replace("line 15\n", "changed 15\n"),
9025        )
9026        .unwrap();
9027        std::fs::write(work.join("new.txt"), "a\nb\n").unwrap();
9028        std::fs::write(work.join("with space.txt"), "a\n").unwrap();
9029        test_git(&work, &["add", "."]);
9030        test_git(&work, &["commit", "-m", "change"]);
9031        let cfg = crate::config::parse(
9032            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9033        )
9034        .unwrap();
9035        let repo = Repo::open(&work, &cfg).unwrap();
9036        assert_eq!(
9037            Hunks::from([
9038                ("new.txt".to_string(), vec![(1, 2)]),
9039                ("src.txt".to_string(), vec![(2, 8), (12, 18)]),
9040                ("with space.txt".to_string(), vec![(1, 1)]),
9041            ]),
9042            repo.diff_hunks(&work, "main")
9043        );
9044        assert!(!marker.exists(), "the text converter ran");
9045        // The converter is real: a plain diff with the same settings runs it.
9046        test_git(&work, &["diff", "origin/main...HEAD"]);
9047        assert!(marker.exists(), "the fixture's converter never runs at all");
9048    }
9049
9050    fn review_fixture(
9051        tag: &str,
9052        number: i64,
9053    ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
9054        use std::sync::atomic::{AtomicU32, Ordering};
9055        static NEXT: AtomicU32 = AtomicU32::new(0);
9056        let id = NEXT.fetch_add(1, Ordering::Relaxed);
9057        let root =
9058            std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
9059        let origin = root.join("origin.git");
9060        let work = root.join("work");
9061        std::fs::create_dir_all(&origin).unwrap();
9062        std::fs::create_dir_all(&work).unwrap();
9063        test_git(&origin, &["init", "--bare", "-b", "main"]);
9064        test_git(&work, &["init", "-b", "main"]);
9065        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9066        test_git(&work, &["config", "user.name", "spar test"]);
9067        test_git(&work, &["config", "commit.gpgsign", "false"]);
9068        test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
9069        test_git(&work, &["config", "filter.drop.smudge", "cat"]);
9070        std::fs::write(work.join("README.md"), "seed\n").unwrap();
9071        std::fs::write(work.join("data.txt"), "old\n").unwrap();
9072        std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
9073        std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
9074        test_git(&work, &["add", "."]);
9075        test_git(&work, &["commit", "-m", "seed"]);
9076        test_git(
9077            &work,
9078            &["remote", "add", "origin", origin.to_str().unwrap()],
9079        );
9080        test_git(&work, &["push", "-u", "origin", "main"]);
9081        test_git(
9082            &work,
9083            &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
9084        );
9085        let cfg = crate::config::parse(
9086            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9087        )
9088        .unwrap();
9089        let repo = Repo::open(&work, &cfg).unwrap();
9090        let path = repo.worktree_for_pr_head(number).unwrap();
9091        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9092        (ReviewFixture { root }, repo, path, checkpoint)
9093    }
9094
9095    #[test]
9096    fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
9097        let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
9098
9099        repo.release_review_worktree_checked(901, &checkpoint)
9100            .unwrap();
9101
9102        assert!(!path.exists());
9103    }
9104
9105    #[test]
9106    fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
9107        let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
9108        let work = fixture.root.join("work");
9109        let seed = test_git(&work, &["rev-parse", "HEAD"]);
9110        let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
9111        // Heads only the pull request holds, the second rewritten rather than
9112        // built on the first, as a rebase and force push leave them.
9113        let publish = |message: &str| {
9114            let oid = test_git(
9115                &work,
9116                &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
9117            );
9118            let oid = oid.trim().to_string();
9119            test_git(
9120                &work,
9121                &[
9122                    "push",
9123                    "--force",
9124                    "origin",
9125                    &format!("{oid}:refs/pull/923/head"),
9126                ],
9127            );
9128            oid
9129        };
9130        let reviewed = publish("reviewed head");
9131        repo.worktree_for_pr_head(923).unwrap();
9132        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
9133
9134        let rewritten = publish("rewritten head");
9135        repo.worktree_for_pr_head(923).unwrap();
9136
9137        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
9138    }
9139
9140    #[test]
9141    fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
9142        let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
9143        let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
9144        std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
9145        test_git(&path, &["add", "recovery.txt"]);
9146        test_git(&path, &["commit", "-m", "recovery commit"]);
9147        let recovery = test_git(&path, &["rev-parse", "HEAD"]);
9148        test_git(&path, &["reset", "--hard", "main"]);
9149
9150        assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
9151        test_git(
9152            &path,
9153            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9154        );
9155    }
9156
9157    #[test]
9158    fn a_review_ref_reflog_only_commit_prevents_deletion() {
9159        let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
9160        let local_ref = review_ref(921);
9161        let original = test_git(&path, &["rev-parse", &local_ref]);
9162        let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
9163        let recovery = test_git(
9164            &path,
9165            &[
9166                "commit-tree",
9167                tree.trim(),
9168                "-p",
9169                original.trim(),
9170                "-m",
9171                "review ref recovery",
9172            ],
9173        );
9174        test_git(
9175            &path,
9176            &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
9177        );
9178        test_git(
9179            &path,
9180            &["update-ref", &local_ref, original.trim(), recovery.trim()],
9181        );
9182
9183        assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
9184        assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
9185        test_git(
9186            &path,
9187            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9188        );
9189    }
9190
9191    #[test]
9192    fn an_unpublished_commit_message_draft_is_recoverable() {
9193        let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
9194        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9195        let git_dir = if raw.is_absolute() {
9196            raw
9197        } else {
9198            path.join(raw)
9199        };
9200        std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
9201
9202        assert!(repository_has_recoverable_work(&path, true).unwrap());
9203        assert_eq!(
9204            "unique recovery draft\n",
9205            std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
9206        );
9207    }
9208
9209    #[test]
9210    fn a_changed_review_worktree_is_retained_after_a_checked_read() {
9211        let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
9212        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9213
9214        let error = repo
9215            .release_review_worktree_checked(902, &checkpoint)
9216            .unwrap_err();
9217
9218        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9219        assert!(error.to_string().contains("kept for recovery"), "{error}");
9220        assert_eq!(
9221            "recover me\n",
9222            std::fs::read_to_string(path.join("README.md")).unwrap()
9223        );
9224        repo.release_review_worktree(902);
9225    }
9226
9227    #[test]
9228    fn a_review_commit_is_retained_after_a_checked_read() {
9229        let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
9230        std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
9231        test_git(&path, &["add", "review-note.txt"]);
9232        test_git(&path, &["commit", "-m", "local review recovery"]);
9233        let head = test_git(&path, &["rev-parse", "HEAD"]);
9234
9235        let error = repo
9236            .release_review_worktree_checked(903, &checkpoint)
9237            .unwrap_err();
9238
9239        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9240        assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
9241        assert_eq!(
9242            "recover me\n",
9243            std::fs::read_to_string(path.join("review-note.txt")).unwrap()
9244        );
9245        repo.release_review_worktree(903);
9246    }
9247
9248    /// The read is no longer what refuses: a file the call created and Git was
9249    /// told to ignore is its own output. Removal still refuses, so the file is
9250    /// there to look at, which is the part that mattered.
9251    #[test]
9252    fn an_ignored_review_file_is_retained_after_a_checked_read() {
9253        let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
9254        std::fs::create_dir_all(path.join("generated")).unwrap();
9255        std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
9256
9257        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9258            .expect("its own output is not a change");
9259        let error = repo
9260            .release_review_worktree_checked(904, &checkpoint)
9261            .unwrap_err();
9262
9263        assert!(error.to_string().contains("recoverable"), "{error}");
9264        assert_eq!(
9265            "recover me\n",
9266            std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
9267        );
9268        repo.release_review_worktree(904);
9269    }
9270
9271    #[test]
9272    /// The checkpoint no longer refuses this: a path the project ignores is out
9273    /// of scope for a call that only read. What keeps the file is the gate that
9274    /// actually deletes the checkout, which refuses any ignored file outside a
9275    /// known build directory. That is the check with the stake in it, and it is
9276    /// the one that has to hold.
9277    fn a_preexisting_ignored_review_file_change_is_retained() {
9278        let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
9279        std::fs::create_dir_all(path.join("generated")).unwrap();
9280        let ignored = path.join("generated/recovery.txt");
9281        std::fs::write(&ignored, "before\n").unwrap();
9282        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9283        std::fs::write(&ignored, "after!\n").unwrap();
9284
9285        let error = repo
9286            .release_review_worktree_checked(905, &checkpoint)
9287            .unwrap_err();
9288
9289        assert!(error.to_string().contains("recoverable"), "{error}");
9290        assert!(path.exists());
9291        assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
9292        repo.release_review_worktree(905);
9293    }
9294
9295    #[test]
9296    fn a_preexisting_ignored_review_file_prevents_checked_removal() {
9297        let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
9298        std::fs::create_dir_all(path.join("generated")).unwrap();
9299        let ignored = path.join("generated/recovery.txt");
9300        std::fs::write(&ignored, "keep me\n").unwrap();
9301        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9302
9303        let error = repo
9304            .release_review_worktree_checked(906, &checkpoint)
9305            .unwrap_err();
9306
9307        assert!(error.to_string().contains("recoverable"), "{error}");
9308        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
9309    }
9310
9311    #[test]
9312    fn overwriting_a_preexisting_untracked_file_is_detected() {
9313        let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
9314        let untracked = path.join("notes.txt");
9315        std::fs::write(&untracked, "before\n").unwrap();
9316        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9317        std::fs::write(&untracked, "after!\n").unwrap();
9318
9319        let error = repo
9320            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9321            .unwrap_err();
9322
9323        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9324        assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
9325    }
9326
9327    #[test]
9328    fn an_assume_unchanged_edit_is_detected() {
9329        let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
9330        test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
9331        std::fs::write(path.join("README.md"), "hidden\n").unwrap();
9332
9333        let error = repo
9334            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9335            .unwrap_err();
9336
9337        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9338        assert_eq!(
9339            "hidden\n",
9340            std::fs::read_to_string(path.join("README.md")).unwrap()
9341        );
9342    }
9343
9344    #[test]
9345    fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
9346        let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
9347        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9348        test_git(&path, &["add", "README.md"]);
9349        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9350
9351        let error = repo
9352            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9353            .unwrap_err();
9354
9355        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9356        assert_eq!(
9357            b"seed\r\n",
9358            std::fs::read(path.join("README.md")).unwrap().as_slice()
9359        );
9360    }
9361
9362    #[cfg(unix)]
9363    #[test]
9364    fn a_mode_edit_is_detected_when_filemode_is_disabled() {
9365        use std::os::unix::fs::PermissionsExt;
9366
9367        let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
9368        test_git(&path, &["config", "core.filemode", "false"]);
9369        let readme = path.join("README.md");
9370        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9371        permissions.set_mode(0o755);
9372        std::fs::set_permissions(&readme, permissions).unwrap();
9373        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9374
9375        let error = repo
9376            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9377            .unwrap_err();
9378
9379        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9380        assert_eq!(
9381            0o755,
9382            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9383        );
9384    }
9385
9386    #[test]
9387    fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
9388        let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
9389        std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
9390        test_git(&path, &["add", ".gitattributes"]);
9391        test_git(&path, &["commit", "-m", "select data filter"]);
9392        let baseline = repo.worktree_baseline(&path).unwrap();
9393        std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
9394
9395        assert!(repo
9396            .commit_pending_changes(&path, &baseline, "change data", "change data")
9397            .unwrap());
9398        let error = repo
9399            .refuse_unrepresented_tracked_changes(&path, &baseline)
9400            .unwrap_err();
9401
9402        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9403        assert_eq!(
9404            "secret: recover me\nnew\n",
9405            std::fs::read_to_string(path.join("data.txt")).unwrap()
9406        );
9407        assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
9408    }
9409
9410    #[test]
9411    fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
9412        let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
9413        std::fs::create_dir_all(path.join("target")).unwrap();
9414        let untracked = path.join("target/user.yaml");
9415        std::fs::write(&untracked, "user data\n").unwrap();
9416        let baseline = repo.worktree_baseline(&path).unwrap();
9417        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9418
9419        assert!(repo
9420            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9421            .unwrap());
9422
9423        assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
9424        assert_eq!(
9425            "?? target/user.yaml\n",
9426            test_git(&path, &["status", "--short", "--untracked-files=all"])
9427        );
9428        assert!(test_git(
9429            &path,
9430            &[
9431                "ls-tree",
9432                "-r",
9433                "--name-only",
9434                "HEAD",
9435                "--",
9436                "target/user.yaml"
9437            ]
9438        )
9439        .is_empty());
9440    }
9441
9442    #[test]
9443    fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
9444        let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
9445        std::fs::create_dir_all(path.join("target")).unwrap();
9446        let untracked = path.join("target/user.yaml");
9447        std::fs::write(&untracked, "before\n").unwrap();
9448        let baseline = repo.worktree_baseline(&path).unwrap();
9449        let before = test_git(&path, &["rev-parse", "HEAD"]);
9450        std::fs::write(&untracked, "after\n").unwrap();
9451        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9452
9453        let error = repo
9454            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9455            .unwrap_err();
9456
9457        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9458        assert!(error.to_string().contains("target/user.yaml"), "{error}");
9459        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
9460        assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
9461        assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
9462    }
9463
9464    #[test]
9465    fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
9466        let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
9467        let baseline = repo.worktree_baseline(&path).unwrap();
9468        std::fs::create_dir_all(path.join("target")).unwrap();
9469        std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
9470
9471        assert!(repo
9472            .commit_pending_changes(&path, &baseline, "add file", "add file")
9473            .unwrap());
9474
9475        assert_eq!(
9476            "new file\n",
9477            test_git(&path, &["show", "HEAD:target/new.txt"])
9478        );
9479        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9480    }
9481
9482    #[test]
9483    fn a_managed_commit_accepts_a_custom_output_rebuild() {
9484        let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
9485        exclude_paths(&repo, &["manager/public/"]);
9486        let output = path.join("manager/public/assets");
9487        std::fs::create_dir_all(&output).unwrap();
9488        std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
9489        std::fs::write(output.join("index.css"), "old styles\n").unwrap();
9490        let baseline = repo.worktree_baseline(&path).unwrap();
9491        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9492        std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
9493        std::fs::remove_file(output.join("old.js")).unwrap();
9494        std::fs::write(output.join("index.css"), "new styles\n").unwrap();
9495        std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
9496
9497        assert!(repo
9498            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9499            .unwrap());
9500        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9501        repo.refuse_changed_existing_untracked(&path, &baseline)
9502            .unwrap();
9503        repo.refuse_unrepresented_tracked_changes(&path, &baseline)
9504            .unwrap();
9505
9506        assert_eq!(
9507            "tracked change\n",
9508            test_git(&path, &["show", "HEAD:README.md"])
9509        );
9510        assert_eq!(
9511            "new source\n",
9512            test_git(&path, &["show", "HEAD:new-source.txt"])
9513        );
9514        assert!(test_git(
9515            &path,
9516            &[
9517                "ls-tree",
9518                "-r",
9519                "--name-only",
9520                "HEAD",
9521                "--",
9522                "manager/public"
9523            ]
9524        )
9525        .is_empty());
9526        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9527        assert!(!output.join("old.js").exists());
9528        assert_eq!(
9529            "new styles\n",
9530            std::fs::read_to_string(output.join("index.css")).unwrap()
9531        );
9532        assert!(repository_has_recoverable_work(&path, true).unwrap());
9533        repo.release_review_worktree(912);
9534        assert!(output.join("index-DUym-Rfj.js").exists());
9535    }
9536
9537    #[test]
9538    fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
9539        let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
9540        std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
9541        let baseline = repo.worktree_baseline(&path).unwrap();
9542        std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
9543        std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
9544
9545        let error = repo
9546            .commit_pending_changes(&path, &baseline, "change", "change")
9547            .unwrap_err();
9548
9549        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9550        assert!(error.to_string().contains("notes.txt"), "{error}");
9551    }
9552
9553    #[test]
9554    fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
9555        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
9556        let baseline = repo.worktree_baseline(&path).unwrap();
9557        let nested = path.join("generated/repro");
9558        std::fs::create_dir_all(&nested).unwrap();
9559        test_git(&nested, &["init"]);
9560        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9561
9562        assert!(repo
9563            .commit_pending_changes(&path, &baseline, "change", "change")
9564            .unwrap());
9565        assert!(!repo.has_uncommitted_changes(&path).unwrap());
9566        assert!(repository_has_recoverable_work(&path, true).unwrap());
9567        repo.release_review_worktree(927);
9568        assert!(nested.join(".git").exists());
9569    }
9570
9571    #[test]
9572    fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
9573        let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
9574        test_git(&path, &["config", "core.autocrlf", "true"]);
9575        std::fs::write(path.join("README.md"), "seed\n").unwrap();
9576        assert_eq!(
9577            test_git(&path, &["hash-object", "README.md"]).trim(),
9578            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9579        );
9580
9581        assert!(repository_has_recoverable_work(&path, true).unwrap());
9582        assert_eq!(
9583            "seed\n",
9584            std::fs::read_to_string(path.join("README.md")).unwrap()
9585        );
9586    }
9587
9588    #[test]
9589    fn autocrlf_input_overrides_a_crlf_core_eol() {
9590        let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
9591        test_git(&path, &["config", "core.autocrlf", "input"]);
9592        test_git(&path, &["config", "core.eol", "crlf"]);
9593        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9594        assert_eq!(
9595            test_git(&path, &["hash-object", "README.md"]).trim(),
9596            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9597        );
9598
9599        assert!(repository_has_recoverable_work(&path, true).unwrap());
9600        assert_eq!(
9601            b"seed\r\n",
9602            std::fs::read(path.join("README.md")).unwrap().as_slice()
9603        );
9604    }
9605
9606    #[cfg(unix)]
9607    #[test]
9608    fn a_non_executable_permission_change_is_recoverable() {
9609        use std::os::unix::fs::PermissionsExt;
9610
9611        let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
9612        let readme = path.join("README.md");
9613        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9614        permissions.set_mode(0o600);
9615        std::fs::set_permissions(&readme, permissions).unwrap();
9616        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9617
9618        let error = repo
9619            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9620            .unwrap_err();
9621
9622        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9623        assert!(repository_has_recoverable_work(&path, true).unwrap());
9624        assert_eq!(
9625            0o600,
9626            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9627        );
9628    }
9629
9630    #[cfg(unix)]
9631    #[test]
9632    fn a_managed_commit_skips_signing_and_hooks() {
9633        use std::os::unix::fs::PermissionsExt;
9634
9635        let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
9636        let common = common_git_dir(&path).unwrap();
9637        let hook = common.join("hooks/pre-commit");
9638        let marker = fixture.root.join("hook-ran");
9639        std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
9640        std::fs::write(
9641            &hook,
9642            format!(
9643                "#!/bin/sh\nprintf ran > {}\nexit 1\n",
9644                sh_quote(marker.to_str().unwrap())
9645            ),
9646        )
9647        .unwrap();
9648        let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
9649        permissions.set_mode(0o755);
9650        std::fs::set_permissions(&hook, permissions).unwrap();
9651        test_git(&path, &["config", "commit.gpgsign", "true"]);
9652        test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
9653        std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
9654        test_git(&path, &["add", "managed.txt"]);
9655
9656        repo.commit_staged_changes(&path, "record managed change")
9657            .unwrap();
9658
9659        assert!(!marker.exists());
9660        assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
9661    }
9662
9663    #[test]
9664    fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
9665        let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
9666        std::fs::write(
9667            path.join(".gitattributes"),
9668            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
9669        )
9670        .unwrap();
9671        test_git(&path, &["add", ".gitattributes"]);
9672        test_git(&path, &["commit", "-m", "select automatic text"]);
9673        test_git(&path, &["config", "core.autocrlf", "true"]);
9674
9675        assert!(repository_has_recoverable_work(&path, true).unwrap());
9676    }
9677
9678    #[test]
9679    fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
9680        let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
9681        std::fs::write(
9682            path.join(".gitattributes"),
9683            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
9684        )
9685        .unwrap();
9686        test_git(&path, &["add", ".gitattributes"]);
9687        test_git(&path, &["commit", "-m", "select ident expansion"]);
9688        std::fs::write(path.join("README.md"), "seed\n").unwrap();
9689
9690        assert!(repository_has_recoverable_work(&path, true).unwrap());
9691    }
9692
9693    /// Add ignore rules the way SPAR does, without a tracked change the
9694    /// worktree would then be kept for.
9695    /// The failure this whole rule was rewritten for: a reviewer ran the
9696    /// project's binding generator to check a finding, and the lockfile it left
9697    /// beside its own manifest matched no directory name spar knew.
9698    #[test]
9699    fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
9700        let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
9701        exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
9702        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9703        std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
9704        std::fs::write(
9705            path.join("bindings/uniffi-bindgen/Cargo.lock"),
9706            "# generated\n",
9707        )
9708        .unwrap();
9709
9710        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9711            .expect("a path the project ignores is the call's own output");
9712    }
9713
9714    #[test]
9715    fn an_attribute_file_under_build_output_is_not_a_filter_change() {
9716        let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
9717        exclude_paths(&repo, &["target/"]);
9718        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9719        std::fs::create_dir_all(path.join("target/package")).unwrap();
9720        std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
9721
9722        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9723            .expect("an ignored attribute file governs only ignored paths");
9724        assert!(
9725            !repo.has_uncommitted_changes(&path).unwrap(),
9726            "ignored dependency attributes must not stop the next review"
9727        );
9728    }
9729
9730    #[test]
9731    fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
9732        let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
9733        std::fs::create_dir_all(path.join("source")).unwrap();
9734        std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
9735
9736        assert!(repo.has_uncommitted_changes(&path).unwrap());
9737    }
9738
9739    #[test]
9740    fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
9741        for source in ["repository", "nested", "local", "global"] {
9742            let (_fixture, repo, path, _initial) = review_fixture(source, 945);
9743            std::fs::create_dir_all(path.join("manager")).unwrap();
9744            match source {
9745                "repository" => {
9746                    std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
9747                        .unwrap();
9748                    test_git(&path, &["add", ".gitignore"]);
9749                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
9750                }
9751                "nested" => {
9752                    std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
9753                    test_git(&path, &["add", "manager/.gitignore"]);
9754                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
9755                }
9756                "local" => exclude_paths(&repo, &["manager/scratch/"]),
9757                "global" => {
9758                    let excludes = repo.root().parent().unwrap().join("global-ignore");
9759                    std::fs::write(&excludes, "manager/scratch/\n").unwrap();
9760                    test_git(
9761                        &path,
9762                        &["config", "core.excludesFile", excludes.to_str().unwrap()],
9763                    );
9764                }
9765                _ => unreachable!(),
9766            }
9767            let output = path.join("manager/scratch");
9768            std::fs::create_dir_all(&output).unwrap();
9769            std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
9770            std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
9771            let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9772            std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
9773            std::fs::remove_file(output.join(".DS_Store")).unwrap();
9774            for file in [
9775                ".gitattributes",
9776                "Cargo.lock",
9777                "font.woff2",
9778                "bundle.js",
9779                "cache.db",
9780            ] {
9781                std::fs::write(output.join(file), "generated\n").unwrap();
9782            }
9783            #[cfg(unix)]
9784            std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
9785            let nested = output.join("dependency");
9786            std::fs::create_dir_all(&nested).unwrap();
9787            test_git(&nested, &["init"]);
9788            repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9789                .unwrap_or_else(|error| panic!("{source}: {error}"));
9790            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9791
9792            let baseline = repo.worktree_baseline(&path).unwrap();
9793            std::fs::write(path.join("README.md"), "source fix\n").unwrap();
9794            std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
9795            std::fs::remove_file(output.join("Cargo.lock")).unwrap();
9796            std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
9797            std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
9798            assert!(repo
9799                .commit_pending_changes(&path, &baseline, "fix source", "fix source")
9800                .unwrap_or_else(|error| panic!("{source}: {error}")));
9801            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9802            assert!(test_git(
9803                &path,
9804                &[
9805                    "ls-tree",
9806                    "-r",
9807                    "--name-only",
9808                    "HEAD",
9809                    "--",
9810                    "manager/scratch"
9811                ]
9812            )
9813            .is_empty());
9814            assert!(
9815                repository_has_recoverable_work(&path, true).unwrap(),
9816                "{source}"
9817            );
9818            repo.release_review_worktree(945);
9819            assert_eq!(
9820                "LOCAL=updated\n",
9821                std::fs::read_to_string(output.join(".env.local")).unwrap()
9822            );
9823            assert!(nested.join(".git").exists(), "{source}");
9824        }
9825    }
9826
9827    /// Two reviewers share one checkout. The marker written when the first
9828    /// one's answer was refused used to be a brand new untracked file in the
9829    /// snapshot the second one was compared against.
9830    #[test]
9831    fn a_recovery_marker_is_not_a_change_to_the_worktree() {
9832        let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
9833        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9834        let _ = uncertain_worktree_change(&path, "an earlier call was refused");
9835
9836        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9837            .expect("spar's own marker is not something a call did");
9838    }
9839
9840    #[test]
9841    fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
9842        let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
9843
9844        let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
9845            .expect("a path a build removed is not an unreadable worktree");
9846
9847        assert_eq!(vanished_untracked_file(), gone);
9848    }
9849
9850    /// A path that goes is still a path that was there. Dropping it from the
9851    /// snapshot would let it pass for one the call never touched.
9852    #[test]
9853    fn a_vanished_path_is_not_recorded_as_absent() {
9854        let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
9855        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
9856        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9857        std::fs::remove_file(path.join("notes.txt")).unwrap();
9858
9859        let error = repo
9860            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9861            .unwrap_err();
9862
9863        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9864    }
9865
9866    fn exclude_paths(repo: &Repo, lines: &[&str]) {
9867        use std::io::Write;
9868        let path = repo.root().join(".git").join("info").join("exclude");
9869        let mut file = std::fs::OpenOptions::new()
9870            .create(true)
9871            .append(true)
9872            .open(&path)
9873            .unwrap();
9874        for line in lines {
9875            writeln!(file, "{line}").unwrap();
9876        }
9877    }
9878
9879    #[test]
9880    fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
9881        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
9882        exclude_paths(&repo, &["dist/"]);
9883        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9884        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9885        let baseline = repo.worktree_baseline(&path).unwrap();
9886        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9887        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9888        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9889        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9890
9891        assert!(repo
9892            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9893            .unwrap());
9894        assert_eq!(
9895            "tracked change\n",
9896            std::fs::read_to_string(path.join("README.md")).unwrap()
9897        );
9898    }
9899
9900    #[test]
9901    fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
9902        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
9903        exclude_paths(&repo, &["dist/"]);
9904        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9905        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9906        std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
9907        let baseline = repo.worktree_baseline(&path).unwrap();
9908        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9909        std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
9910        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9911        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9912
9913        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9914        repo.refuse_changed_existing_untracked(&path, &baseline)
9915            .unwrap();
9916    }
9917
9918    #[test]
9919    fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
9920        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
9921        exclude_paths(&repo, &["dist/"]);
9922        std::fs::create_dir_all(path.join("dist")).unwrap();
9923        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
9924        let baseline = repo.worktree_baseline(&path).unwrap();
9925        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
9926        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
9927
9928        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9929        repo.refuse_changed_existing_untracked(&path, &baseline)
9930            .unwrap();
9931    }
9932
9933    /// The run this comes from: a reviewer rebuilt a project whose build output
9934    /// lands in `manager/public/assets/`, which no directory list will ever
9935    /// name. The guard inside the call kept the review. This check then called
9936    /// the same thirteen files unrepresentable and ended the round, five
9937    /// minutes of review thrown away over a path Git had been told to disown.
9938    /// The run this comes from: `gh pr merge --delete-branch` could not delete
9939    /// `issue-784` because `.spar-worktrees/issue-784`, left by the run that
9940    /// opened the pull request, still had it checked out.
9941    #[test]
9942    fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
9943        let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
9944        let held = repo.root().join(WORKTREE_DIR).join("issue-950");
9945        std::fs::create_dir_all(held.parent().unwrap()).unwrap();
9946        test_git(
9947            repo.root(),
9948            &[
9949                "worktree",
9950                "add",
9951                "-b",
9952                "issue-950",
9953                held.to_str().unwrap(),
9954                "main",
9955            ],
9956        );
9957
9958        let found = repo
9959            .worktree_holding("issue-950")
9960            .expect("the branch is checked out somewhere");
9961
9962        assert!(found.ends_with("issue-950"), "{}", found.display());
9963        assert_eq!(None, repo.worktree_holding("issue-951"));
9964    }
9965
9966    #[test]
9967    fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
9968        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
9969        exclude_paths(&repo, &["manager/public/assets/"]);
9970        std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
9971        let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
9972        std::fs::write(&bundle, "first build\n").unwrap();
9973        let baseline = repo.worktree_baseline(&path).unwrap();
9974        std::fs::write(&bundle, "second build\n").unwrap();
9975        std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
9976
9977        repo.refuse_read_only_leavings(&path, &baseline).unwrap();
9978        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9979    }
9980
9981    #[test]
9982    fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
9983        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
9984        exclude_paths(&repo, &["dist/"]);
9985        let baseline = repo.worktree_baseline(&path).unwrap();
9986        std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
9987
9988        let error = repo
9989            .refuse_read_only_leavings(&path, &baseline)
9990            .unwrap_err();
9991
9992        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9993        assert!(error.to_string().contains("scratch.md"), "{error}");
9994    }
9995
9996    #[test]
9997    fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
9998        let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
9999        exclude_paths(&repo, &["dist/", ".env.local"]);
10000        std::fs::create_dir_all(path.join("dist")).unwrap();
10001        let baseline = repo.worktree_baseline(&path).unwrap();
10002        std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
10003        std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
10004
10005        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10006        assert!(repository_has_recoverable_work(&path, true).unwrap());
10007    }
10008
10009    #[test]
10010    fn a_read_only_inspection_may_rebuild_generated_output() {
10011        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
10012        exclude_paths(&repo, &["dist/"]);
10013        std::fs::create_dir_all(path.join("dist")).unwrap();
10014        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10015        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10016        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10017        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10018
10019        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10020            .unwrap();
10021    }
10022
10023    #[test]
10024    fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
10025        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
10026        exclude_paths(&repo, &["dist/", ".env.local"]);
10027        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10028        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10029        std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
10030
10031        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10032            .unwrap();
10033    }
10034
10035    #[test]
10036    fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
10037        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
10038        exclude_paths(&repo, &["dist/", ".env.local"]);
10039        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10040        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10041        std::fs::remove_file(path.join(".env.local")).unwrap();
10042
10043        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10044            .unwrap();
10045    }
10046
10047    #[test]
10048    fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
10049        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
10050        exclude_paths(&repo, &["dist/"]);
10051        std::fs::write(path.join("notes.md"), "before\n").unwrap();
10052        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10053        std::fs::write(path.join("notes.md"), "after\n").unwrap();
10054
10055        let error = repo
10056            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10057            .unwrap_err();
10058
10059        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10060        assert!(error.to_string().contains("notes.md"), "{error}");
10061    }
10062
10063    #[test]
10064    fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
10065        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
10066        exclude_paths(&repo, &["dist/", "local.env"]);
10067        std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
10068        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10069        // Stop ignoring it, so the same bytes are now an ordinary untracked
10070        // file. What a path is counts, not only whether it moved.
10071        std::fs::write(
10072            repo.root().join(".git").join("info").join("exclude"),
10073            "dist/\n",
10074        )
10075        .unwrap();
10076
10077        let error = repo
10078            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10079            .unwrap_err();
10080
10081        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10082        assert!(error.to_string().contains("local.env"), "{error}");
10083    }
10084
10085    #[test]
10086    fn a_read_only_inspection_names_the_tracked_file_that_moved() {
10087        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
10088        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10089        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
10090
10091        let error = repo
10092            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10093            .unwrap_err();
10094
10095        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10096        assert!(error.to_string().contains("README.md"), "{error}");
10097    }
10098
10099    #[test]
10100    fn build_output_alone_does_not_keep_a_worktree() {
10101        let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
10102        exclude_paths(&repo, &["target/", "dist/"]);
10103        std::fs::create_dir_all(path.join("target/debug")).unwrap();
10104        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10105        std::fs::create_dir_all(path.join("dist/cli")).unwrap();
10106        std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
10107
10108        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10109        repo.release_review_worktree(933);
10110
10111        assert!(!path.exists());
10112    }
10113
10114    #[test]
10115    fn finder_metadata_alone_does_not_keep_a_worktree() {
10116        let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
10117        exclude_paths(&repo, &[".DS_Store"]);
10118        std::fs::create_dir_all(path.join("src")).unwrap();
10119        std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
10120        std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
10121
10122        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10123        repo.release_review_worktree(937);
10124
10125        assert!(!path.exists());
10126    }
10127
10128    #[test]
10129    fn a_fetch_record_alone_does_not_keep_a_worktree() {
10130        let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
10131        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
10132        let git_dir = if raw.is_absolute() {
10133            raw
10134        } else {
10135            path.join(raw)
10136        };
10137        let head = test_git(&path, &["rev-parse", "HEAD"]);
10138        std::fs::write(
10139            git_dir.join("FETCH_HEAD"),
10140            format!("{}\t\tbranch 'main' of origin\n", head.trim()),
10141        )
10142        .unwrap();
10143
10144        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10145        repo.release_review_worktree(938);
10146
10147        assert!(!path.exists());
10148    }
10149
10150    #[test]
10151    fn an_ignored_file_outside_build_output_keeps_a_worktree() {
10152        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
10153        exclude_paths(&repo, &["target/", ".env.local"]);
10154        std::fs::create_dir_all(path.join("target/debug")).unwrap();
10155        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10156        std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
10157
10158        assert!(repository_has_recoverable_work(&path, true).unwrap());
10159        repo.release_review_worktree(934);
10160
10161        assert_eq!(
10162            "TOKEN=keep me\n",
10163            std::fs::read_to_string(path.join(".env.local")).unwrap()
10164        );
10165    }
10166
10167    #[test]
10168    fn a_repository_nested_in_build_output_keeps_a_worktree() {
10169        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
10170        exclude_paths(&repo, &["node_modules/"]);
10171        let nested = path.join("node_modules/local-dep");
10172        std::fs::create_dir_all(&nested).unwrap();
10173        test_git(&nested, &["init"]);
10174        std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
10175
10176        assert!(repository_has_recoverable_work(&path, true).unwrap());
10177        repo.release_review_worktree(935);
10178
10179        assert!(nested.join(".git").exists());
10180    }
10181
10182    #[test]
10183    fn an_ordinary_untracked_file_keeps_a_worktree() {
10184        let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
10185        std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
10186
10187        assert!(repository_has_recoverable_work(&path, true).unwrap());
10188        repo.release_review_worktree(936);
10189
10190        assert_eq!(
10191            "somebody's notes\n",
10192            std::fs::read_to_string(path.join("notes.md")).unwrap()
10193        );
10194    }
10195
10196    #[test]
10197    fn a_legacy_crlf_checkout_is_retained_conservatively() {
10198        let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
10199        std::fs::write(
10200            path.join(".gitattributes"),
10201            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
10202        )
10203        .unwrap();
10204        test_git(&path, &["add", ".gitattributes"]);
10205        test_git(&path, &["commit", "-m", "select legacy line endings"]);
10206
10207        assert!(repository_has_recoverable_work(&path, true).unwrap());
10208    }
10209
10210    #[test]
10211    fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
10212        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
10213        let nested = path.join("tracked");
10214        std::fs::create_dir_all(&nested).unwrap();
10215        std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
10216        test_git(&path, &["add", "tracked/seed.txt"]);
10217        test_git(&path, &["commit", "-m", "add tracked directory"]);
10218        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10219        test_git(&nested, &["init"]);
10220
10221        let error = repo
10222            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10223            .unwrap_err();
10224
10225        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10226        assert!(error.to_string().contains("Git entry"), "{error}");
10227        assert!(nested.join(".git").exists());
10228    }
10229
10230    /// A reviewer that clones a reproduction into ignored build output used to
10231    /// lose the whole review. The state check refused every Git entry it had
10232    /// not put there, including the one Git reports as a nested checkout and
10233    /// the untracked snapshot already records by identity.
10234    #[test]
10235    fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
10236        let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
10237        exclude_paths(&repo, &["target/"]);
10238        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10239        let repro = path.join("target/pr918-repro");
10240        std::fs::create_dir_all(&repro).unwrap();
10241        test_git(&repro, &["init"]);
10242        std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
10243
10244        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10245            .unwrap();
10246
10247        assert!(repository_has_recoverable_work(&path, true).unwrap());
10248        assert!(repro.join(".git").exists());
10249    }
10250
10251    /// What sits under a nested checkout is that repository's business, so the
10252    /// scan stops at its directory rather than reading a checkout of its own
10253    /// as an entry the outer worktree has to account for.
10254    #[test]
10255    fn a_repository_inside_a_nested_checkout_is_left_alone() {
10256        let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
10257        exclude_paths(&repo, &["target/"]);
10258        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10259        let repro = path.join("target/repro");
10260        let vendored = repro.join("vendor/dep");
10261        std::fs::create_dir_all(&vendored).unwrap();
10262        test_git(&repro, &["init"]);
10263        test_git(&vendored, &["init"]);
10264
10265        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10266            .unwrap();
10267
10268        assert!(vendored.join(".git").exists());
10269    }
10270
10271    #[test]
10272    fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
10273        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
10274
10275        let state = ignored_untracked_state(repo.root()).unwrap();
10276
10277        let relative = path.strip_prefix(repo.root()).unwrap();
10278        assert!(
10279            state.files.contains_key(relative),
10280            "{:?}",
10281            state.files.keys().collect::<Vec<_>>()
10282        );
10283        assert!(state.is_ignored(relative));
10284    }
10285
10286    #[test]
10287    fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
10288        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
10289        let baseline = repo.worktree_baseline(repo.root()).unwrap();
10290        std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
10291        std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
10292
10293        repo.refuse_new_ignored_files(repo.root(), &baseline)
10294            .unwrap();
10295        repo.refuse_changed_existing_untracked(repo.root(), &baseline)
10296            .unwrap();
10297    }
10298
10299    #[test]
10300    fn deleting_a_resident_worktree_during_a_call_is_refused() {
10301        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
10302        let baseline = repo.worktree_baseline(repo.root()).unwrap();
10303        let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
10304        std::fs::remove_dir_all(&path).unwrap();
10305
10306        let read_error = repo
10307            .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
10308            .unwrap_err();
10309        assert!(
10310            read_error.to_string().contains("review-932"),
10311            "{read_error}"
10312        );
10313        let error = repo
10314            .refuse_new_ignored_files(repo.root(), &baseline)
10315            .unwrap_err();
10316
10317        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10318        assert!(error.to_string().contains("review-932"), "{error}");
10319    }
10320
10321    #[test]
10322    fn a_nested_repository_record_is_read_as_a_plain_path() {
10323        let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
10324        assert_eq!(Path::new("vendor/checkout"), path);
10325        assert!(nested);
10326
10327        let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
10328        assert_eq!(Path::new("vendor/notes.txt"), path);
10329        assert!(!nested);
10330
10331        assert!(untracked_record(b"/", "untracked").is_err());
10332    }
10333
10334    #[cfg(unix)]
10335    #[test]
10336    fn a_non_utf8_git_path_is_preserved_without_loss() {
10337        use std::os::unix::ffi::OsStrExt;
10338
10339        let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
10340
10341        assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
10342    }
10343
10344    #[test]
10345    fn guarded_merge_pins_the_reviewed_head() {
10346        let args = merge_pr_args("36", Some("abc123"), true);
10347        assert_eq!(
10348            vec![
10349                "pr",
10350                "merge",
10351                "36",
10352                "--squash",
10353                "--delete-branch",
10354                "--match-head-commit",
10355                "abc123"
10356            ],
10357            args
10358        );
10359    }
10360
10361    #[test]
10362    fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
10363        let pr = PrRef {
10364            number: 7,
10365            url: "https://example.test/pull/7".into(),
10366            title: "part one".into(),
10367        };
10368        let result = reconcile_pr_creation(
10369            "split-34-1",
10370            Err(crate::error::SparError::new("connection lost")),
10371            Ok(Some(pr)),
10372        )
10373        .unwrap();
10374        assert_eq!(7, result.number);
10375    }
10376
10377    #[test]
10378    fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
10379        let error = reconcile_pr_creation(
10380            "split-34-1",
10381            Err(crate::error::SparError::new("permission denied")),
10382            Ok(None),
10383        )
10384        .unwrap_err();
10385        assert!(error.to_string().contains("permission denied"), "{error}");
10386    }
10387
10388    #[test]
10389    fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
10390        let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
10391        assert!(pr_for_base(text, "split-34-2", "split-34-1")
10392            .unwrap()
10393            .is_none());
10394        let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
10395        assert_eq!(7, found.number);
10396    }
10397
10398    #[test]
10399    fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
10400        let result = reconcile_comment_post(
10401            34,
10402            "the summary",
10403            crate::error::SparError::new("connection lost"),
10404            Ok(vec![serde_json::json!({"body": "the summary"})]),
10405        );
10406        assert!(result.is_ok(), "{result:?}");
10407    }
10408
10409    #[test]
10410    fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
10411        let error = reconcile_comment_post(
10412            34,
10413            "the summary",
10414            crate::error::SparError::new("connection lost"),
10415            Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
10416        )
10417        .unwrap_err();
10418        assert_eq!("connection lost", error.to_string());
10419    }
10420
10421    #[test]
10422    fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
10423        let error = reconcile_comment_post(
10424            34,
10425            "the summary",
10426            crate::error::SparError::new("connection lost"),
10427            Err(crate::error::SparError::new("comments unavailable")),
10428        )
10429        .unwrap_err();
10430        assert!(
10431            error.to_string().contains("could not be verified"),
10432            "{error}"
10433        );
10434        assert!(
10435            error.to_string().contains("comments unavailable"),
10436            "{error}"
10437        );
10438        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10439        assert!(!error.worth_retrying());
10440    }
10441
10442    #[test]
10443    fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
10444        let result = reconcile_issue_edit(
10445            34,
10446            "wanted body",
10447            crate::error::SparError::new("connection lost"),
10448            Ok("wanted body".to_string()),
10449        );
10450        assert!(result.is_ok(), "{result:?}");
10451    }
10452
10453    #[test]
10454    fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
10455        let error = reconcile_issue_edit(
10456            34,
10457            "wanted body",
10458            crate::error::SparError::new("connection lost"),
10459            Err(crate::error::SparError::new("issue unavailable")),
10460        )
10461        .unwrap_err();
10462        assert!(
10463            error.to_string().contains("could not be verified"),
10464            "{error}"
10465        );
10466        assert!(error.to_string().contains("issue unavailable"), "{error}");
10467        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10468        assert!(!error.worth_retrying());
10469    }
10470
10471    #[test]
10472    fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
10473        let found = ExistingIssue {
10474            number: 101,
10475            url: "https://example.test/issues/101".into(),
10476            title: "child".into(),
10477            body: "body".into(),
10478            open: true,
10479        };
10480        let url = reconcile_issue_creation(
10481            "child",
10482            Err(crate::error::SparError::new("connection lost")),
10483            Ok(Some(found)),
10484        )
10485        .unwrap();
10486        assert_eq!("https://example.test/issues/101", url);
10487    }
10488
10489    #[test]
10490    fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
10491        let error = reconcile_issue_creation(
10492            "child",
10493            Err(crate::error::SparError::new("permission denied")),
10494            Ok(None),
10495        )
10496        .unwrap_err();
10497        assert!(error.to_string().contains("permission denied"), "{error}");
10498    }
10499
10500    #[test]
10501    fn an_unverifiable_issue_creation_is_marked_uncertain() {
10502        let error = reconcile_issue_creation(
10503            "child",
10504            Err(crate::error::SparError::new("connection lost")),
10505            Err(crate::error::SparError::new("issues unavailable")),
10506        )
10507        .unwrap_err();
10508        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10509        assert!(!error.worth_retrying());
10510    }
10511
10512    #[test]
10513    fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
10514        let result = reconcile_failed_split_push(
10515            "split-34-1",
10516            crate::error::SparError::new("connection lost"),
10517            Ok("abc123\n".into()),
10518            Ok("abc123\trefs/heads/split-34-1\n".into()),
10519        );
10520        assert!(result.is_ok(), "{result:?}");
10521    }
10522
10523    #[test]
10524    fn a_split_push_collision_is_definite_and_never_overwrites() {
10525        let error = reconcile_failed_split_push(
10526            "split-34-1",
10527            crate::error::SparError::new("lease rejected"),
10528            Ok("abc123\n".into()),
10529            Ok("def456\trefs/heads/split-34-1\n".into()),
10530        )
10531        .unwrap_err();
10532        assert!(!error.retain_worktree());
10533        assert!(
10534            error.to_string().contains("Nothing was overwritten"),
10535            "{error}"
10536        );
10537    }
10538
10539    #[test]
10540    fn an_unreadable_split_push_result_keeps_the_worktree() {
10541        let error = reconcile_failed_split_push(
10542            "split-34-1",
10543            crate::error::SparError::new("connection lost"),
10544            Ok("abc123\n".into()),
10545            Err(crate::error::SparError::new("origin unavailable")),
10546        )
10547        .unwrap_err();
10548        assert!(error.retain_worktree());
10549        assert!(error.to_string().contains("could not confirm"), "{error}");
10550    }
10551
10552    /// Follow-up deduplication compares a title it computed against the title
10553    /// GitHub stored. If those two transforms can disagree, the check never
10554    /// matches and every review round files another copy of the same issue.
10555    #[test]
10556    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
10557        let repo = repo_for_titles();
10558        for raw in [
10559            "Retry loop spins \u{2014} Retry-After parses to zero",
10560            "plain title",
10561            "  spread   over\nlines  ",
10562            "\u{1F916} Generated with something",
10563            &format!("a \u{2014} {}", "very long title ".repeat(20)),
10564            &"x".repeat(300),
10565            &format!("{} \u{2014} end", "y".repeat(88)),
10566            // Exactly the budget, with two spaceless dashes. The scrub turns
10567            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
10568            // character over budget per dash and a second pass clips again,
10569            // producing a different string. Scrub-then-clip cannot.
10570            &{
10571                let tail = "a\u{2014}b c\u{2014}d";
10572                let pad = Style::default().max_title_chars - tail.chars().count();
10573                format!("{}{tail}", "w".repeat(pad))
10574            },
10575        ] {
10576            let once = repo.clean_title(raw).unwrap();
10577            let twice = repo.clean_title(&once).unwrap();
10578            assert_eq!(once, twice, "not idempotent for {raw:?}");
10579            assert!(
10580                once.chars().count() <= repo.style.max_title_chars,
10581                "over budget: {once:?}"
10582            );
10583            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
10584        }
10585    }
10586
10587    #[test]
10588    fn a_title_with_an_em_dash_survives_as_readable_text() {
10589        let repo = repo_for_titles();
10590        assert_eq!(
10591            "Retry loop spins, Retry-After parses to zero",
10592            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
10593                .unwrap()
10594        );
10595    }
10596
10597    #[test]
10598    fn sh_quote_survives_a_quote() {
10599        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
10600    }
10601
10602    #[test]
10603    fn sh_quote_wraps_a_space() {
10604        assert_eq!(
10605            "'/Applications/My App/spar'",
10606            sh_quote("/Applications/My App/spar")
10607        );
10608    }
10609
10610    #[test]
10611    fn finished_states_are_recognised_case_insensitively() {
10612        assert!(is_finished("MERGED"));
10613        assert!(is_finished("closed"));
10614        assert!(!is_finished("OPEN"));
10615        assert!(!is_finished(""));
10616    }
10617
10618    fn state() -> PersistedState {
10619        PersistedState {
10620            version: 1,
10621            checkpoint: 0,
10622            round: 4,
10623            next_actor: "codex".into(),
10624            status: Status::Pending,
10625            pr_head: "abc123".into(),
10626            ledger: Ledger::new(),
10627            filed: vec![],
10628            open_findings: vec![Finding {
10629                severity: Severity::Blocking,
10630                title: "Unchecked error".into(),
10631                detail: "the failure is discarded".into(),
10632                file: "src/a.rs:12".into(),
10633                ..Finding::default()
10634            }],
10635            disputes: vec![Dispute {
10636                title: "Retry limit".into(),
10637                file: "src/net.rs".into(),
10638                reasoning: "the caller already bounds it".into(),
10639            }],
10640            noted: vec![Finding {
10641                severity: Severity::NonBlocking,
10642                title: "Timeout is fixed".into(),
10643                file: "src/config.rs".into(),
10644                ..Finding::default()
10645            }],
10646        }
10647    }
10648
10649    #[test]
10650    fn a_state_comment_round_trips() {
10651        let body = format!(
10652            "{STATE_MARKER}\n{}\n-->",
10653            serde_json::to_string(&state()).unwrap()
10654        );
10655        let back = parse_state_comment(&body).unwrap();
10656        assert_eq!(4, back.round);
10657        assert_eq!("codex", back.next_actor);
10658        assert_eq!("abc123", back.pr_head);
10659        assert_eq!("Unchecked error", back.open_findings[0].title);
10660        assert_eq!("src/net.rs", back.disputes[0].file);
10661        assert_eq!("Timeout is fixed", back.noted[0].title);
10662    }
10663
10664    #[test]
10665    fn old_state_without_new_lists_still_parses() {
10666        let body = format!(
10667            "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
10668             \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
10669        );
10670        let back = parse_state_comment(&body).expect("old state");
10671        assert!(back.open_findings.is_empty());
10672        assert!(back.disputes.is_empty());
10673        assert!(back.noted.is_empty());
10674        assert!(back.pr_head.is_empty());
10675        assert_eq!(0, back.checkpoint);
10676    }
10677
10678    #[test]
10679    fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
10680        let mut local = state();
10681        local.pr_head = "old".into();
10682        local.round = 9;
10683        let mut remote = state();
10684        remote.pr_head = "current".into();
10685        remote.round = 4;
10686
10687        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10688        assert_eq!("current", chosen.pr_head);
10689        assert_eq!(4, chosen.round);
10690    }
10691
10692    #[test]
10693    fn checkpoint_order_breaks_same_round_ties() {
10694        let mut local = state();
10695        local.pr_head = "current".into();
10696        local.round = 4;
10697        local.checkpoint = 8;
10698        let mut remote = local.clone();
10699        remote.checkpoint = 7;
10700        remote.open_findings.clear();
10701
10702        let chosen = choose_state_for_head(vec![local], "current").unwrap();
10703        assert_eq!(8, chosen.checkpoint);
10704
10705        let mut local = state();
10706        local.pr_head = "current".into();
10707        local.round = 4;
10708        local.checkpoint = 8;
10709        let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
10710        assert_eq!(8, chosen.checkpoint);
10711    }
10712
10713    #[test]
10714    fn legacy_same_round_tie_keeps_the_local_checkpoint() {
10715        let mut local = state();
10716        local.pr_head = "current".into();
10717        local.round = 4;
10718        local.open_findings.push(Finding {
10719            title: "local checkpoint".into(),
10720            ..Finding::default()
10721        });
10722        let mut remote = state();
10723        remote.pr_head = "current".into();
10724        remote.round = 4;
10725
10726        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10727        assert_eq!(
10728            "local checkpoint",
10729            chosen.open_findings.last().unwrap().title
10730        );
10731    }
10732
10733    /// It must render as nothing, so PRs are not littered with machine state.
10734    #[test]
10735    fn the_state_block_is_an_html_comment() {
10736        let body = format!(
10737            "{STATE_MARKER}\n{}\n-->",
10738            serde_json::to_string(&state()).unwrap()
10739        );
10740        assert!(body.starts_with("<!--"));
10741        assert!(body.trim_end().ends_with("-->"));
10742        assert!(!body[..body.find('{').unwrap()].contains("-->"));
10743    }
10744
10745    #[test]
10746    fn an_unrelated_json_block_is_not_state() {
10747        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
10748    }
10749
10750    /// Patching and deleting a comment is not something to do on a substring.
10751    ///
10752    /// A maintainer who notices the block and asks about it writes a comment
10753    /// containing `spar:state`, and the loose filter could not tell theirs from
10754    /// spar's. The next `persist` replaced their words with JSON and
10755    /// `spar clean --pr-state` later deleted it.
10756    #[test]
10757    fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
10758        let block = format!(
10759            "{STATE_MARKER}\n{}\n-->",
10760            serde_json::to_string(&state()).unwrap()
10761        );
10762        assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
10763        assert!(
10764            is_state_comment(&block, "Spar-Bot", "spar-bot"),
10765            "a login differs only in case"
10766        );
10767
10768        assert!(
10769            !is_state_comment(
10770                "why is there a `<!-- spar:state` block on this PR?",
10771                "maintainer",
10772                "spar-bot"
10773            ),
10774            "a question about the block was treated as the block"
10775        );
10776        assert!(
10777            !is_state_comment(&block, "maintainer", "spar-bot"),
10778            "somebody else's comment is not spar's to rewrite"
10779        );
10780        assert!(
10781            !is_state_comment(
10782                &format!("here is what spar writes:\n\n{block}"),
10783                "spar-bot",
10784                "spar-bot"
10785            ),
10786            "the marker has to open the body, not appear in it"
10787        );
10788        assert!(
10789            !is_state_comment(
10790                &format!("{STATE_MARKER}\n{{not json\n-->"),
10791                "spar-bot",
10792                "spar-bot"
10793            ),
10794            "a block that does not parse is not state"
10795        );
10796        assert!(
10797            !is_state_comment(&block, "spar-bot", ""),
10798            "an unknown viewer proves nothing about who wrote it"
10799        );
10800    }
10801
10802    #[test]
10803    fn a_malformed_state_comment_is_none_not_a_panic() {
10804        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
10805    }
10806
10807    #[test]
10808    fn atomic_write_leaves_no_temp_file() {
10809        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
10810        let _ = std::fs::remove_dir_all(&dir);
10811        let path = dir.join("state").join("pr-7.json");
10812        write_json_atomic(&path, &state()).unwrap();
10813        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
10814            .unwrap()
10815            .flatten()
10816            .filter_map(|e| e.file_name().to_str().map(str::to_string))
10817            .collect();
10818        assert_eq!(vec!["pr-7.json".to_string()], files);
10819        let _ = std::fs::remove_dir_all(&dir);
10820    }
10821
10822    #[test]
10823    fn atomic_write_overwrites_rather_than_accumulating() {
10824        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
10825        let _ = std::fs::remove_dir_all(&dir);
10826        let path = dir.join("pr-7.json");
10827        for round in 1..4 {
10828            let mut s = state();
10829            s.round = round;
10830            write_json_atomic(&path, &s).unwrap();
10831        }
10832        let back: PersistedState =
10833            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10834        assert_eq!(3, back.round);
10835        let _ = std::fs::remove_dir_all(&dir);
10836    }
10837
10838    #[test]
10839    fn style_from_env_defaults_to_enforcing() {
10840        std::env::remove_var("SPAR_BAN_EM_DASH");
10841        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
10842        let style = style_from_env();
10843        assert!(style.ban_em_dash && style.ban_ai_attribution);
10844        assert!(
10845            !style.terse,
10846            "the commit filter must not truncate a commit message"
10847        );
10848    }
10849
10850    // -- publishing from a fork --------------------------------------------
10851
10852    /// GitHub's refusal is several lines of advice after the one that says
10853    /// what happened, and spar used to report only the last of them.
10854    #[test]
10855    fn a_push_denial_is_read_from_either_transport() {
10856        let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
10857                   fatal: Could not read from remote repository.\n\n\
10858                   Please make sure you have the correct access rights\n\
10859                   and the repository exists.";
10860        let https = "remote: Permission to example/project.git denied to someone.\n\
10861                     fatal: unable to access 'https://github.com/example/project.git/': The \
10862                     requested URL returned error: 403";
10863        let denied = Some("Permission to example/project.git denied to someone.");
10864        assert_eq!(denied, push_denial(ssh));
10865        assert_eq!(denied, push_denial(https));
10866
10867        let stale = "To github.com:example/project.git\n \
10868                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
10869                     error: failed to push some refs to 'github.com:example/project.git'";
10870        assert_eq!(None, push_denial(stale));
10871
10872        let err = push_error(
10873            &PushTarget::Origin,
10874            "issue-7",
10875            &crate::error::SparError::new(ssh),
10876        );
10877        assert_eq!(
10878            "could not push to origin/issue-7. Permission to example/project.git denied to \
10879             someone.",
10880            err.to_string()
10881        );
10882    }
10883
10884    /// An origin whose hook refuses every push with GitHub's wording, a bare
10885    /// repository standing in for the fork, and a checkout on a new branch
10886    /// with one commit to publish.
10887    #[cfg(unix)]
10888    fn denied_fixture(
10889        tag: &str,
10890        fork_when_denied: bool,
10891    ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
10892        use std::os::unix::fs::PermissionsExt;
10893        use std::sync::atomic::{AtomicU32, Ordering};
10894        static NEXT: AtomicU32 = AtomicU32::new(0);
10895        let id = NEXT.fetch_add(1, Ordering::Relaxed);
10896        let root =
10897            std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
10898        let origin = root.join("origin.git");
10899        let fork = root.join("fork.git");
10900        let work = root.join("work");
10901        for dir in [&origin, &fork, &work] {
10902            std::fs::create_dir_all(dir).unwrap();
10903        }
10904        test_git(&origin, &["init", "--bare", "-b", "main"]);
10905        test_git(&fork, &["init", "--bare", "-b", "main"]);
10906        test_git(&work, &["init", "-b", "main"]);
10907        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
10908        test_git(&work, &["config", "user.name", "spar test"]);
10909        test_git(&work, &["config", "commit.gpgsign", "false"]);
10910        std::fs::write(work.join("README.md"), "seed\n").unwrap();
10911        test_git(&work, &["add", "."]);
10912        test_git(&work, &["commit", "-m", "seed"]);
10913        test_git(
10914            &work,
10915            &["remote", "add", "origin", origin.to_str().unwrap()],
10916        );
10917        test_git(&work, &["push", "-q", "origin", "main"]);
10918        // Installed after the seed, so it is only spar's pushes it refuses.
10919        let hook = origin.join("hooks").join("pre-receive");
10920        std::fs::write(
10921            &hook,
10922            "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
10923        )
10924        .unwrap();
10925        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
10926        test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
10927        std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
10928        test_git(&work, &["add", "."]);
10929        test_git(&work, &["commit", "-m", "Fix it"]);
10930
10931        let mut text =
10932            String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
10933        if !fork_when_denied {
10934            text.push_str("[loop]\nfork_when_denied = false\n");
10935        }
10936        let cfg = crate::config::parse(&text).unwrap();
10937        let repo = Repo::open(&work, &cfg).unwrap();
10938        // What `fork` would have asked GitHub for, so no test reaches it.
10939        repo.fork
10940            .set(Fork {
10941                owner: "someone".into(),
10942                full_name: "someone/project".into(),
10943                url: fork.to_str().unwrap().into(),
10944            })
10945            .unwrap();
10946        (ReviewFixture { root }, repo, work, origin, fork)
10947    }
10948
10949    #[cfg(unix)]
10950    #[test]
10951    fn a_new_branch_origin_refuses_is_published_from_the_fork() {
10952        let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
10953        let first = test_git(&work, &["rev-parse", "HEAD"]);
10954
10955        let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
10956
10957        assert_eq!("someone:issue-7", target.head("issue-7"));
10958        assert_eq!(
10959            first.trim(),
10960            test_git(&fork, &["rev-parse", "issue-7"]).trim()
10961        );
10962        assert!(test_git(&origin, &["branch", "--list", "issue-7"])
10963            .trim()
10964            .is_empty());
10965        assert_eq!(
10966            WriteSummary {
10967                attempted: 1,
10968                failed: 0
10969            },
10970            repo.write_summary(),
10971            "a refusal the fork answered is not a failed write"
10972        );
10973
10974        // A later round follows the branch to the fork, leasing what it
10975        // published there.
10976        std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
10977        test_git(&work, &["commit", "-qam", "Fix it again"]);
10978        repo.push_to(&work, &target, "issue-7", Some(first.trim()))
10979            .unwrap();
10980        let second = test_git(&work, &["rev-parse", "HEAD"]);
10981        assert_eq!(
10982            second.trim(),
10983            test_git(&fork, &["rev-parse", "issue-7"]).trim()
10984        );
10985    }
10986
10987    /// What a closed pull request leaves on the fork: a branch with nothing
10988    /// a pull request does not hold. Origin's issue branches are rebuilt over
10989    /// in the same case.
10990    #[cfg(unix)]
10991    #[test]
10992    fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
10993        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
10994        test_git(
10995            &work,
10996            &[
10997                "push",
10998                "-q",
10999                fork.to_str().unwrap(),
11000                "main:refs/heads/issue-7",
11001            ],
11002        );
11003        let head = test_git(&work, &["rev-parse", "HEAD"]);
11004
11005        repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11006
11007        assert_eq!(
11008            head.trim(),
11009            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11010        );
11011        assert_eq!(0, repo.write_summary().failed);
11012    }
11013
11014    #[cfg(unix)]
11015    #[test]
11016    fn work_only_the_fork_has_is_not_published_over() {
11017        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
11018        test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
11019        std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
11020        test_git(&work, &["add", "."]);
11021        test_git(&work, &["commit", "-qm", "Somebody's work"]);
11022        test_git(
11023            &work,
11024            &[
11025                "push",
11026                "-q",
11027                fork.to_str().unwrap(),
11028                "elsewhere:refs/heads/issue-7",
11029            ],
11030        );
11031        let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
11032        test_git(&work, &["checkout", "-q", "issue-7"]);
11033
11034        let err = repo
11035            .publish_new_branch(&work, "issue-7", "main")
11036            .unwrap_err()
11037            .to_string();
11038
11039        assert!(
11040            err.contains(
11041                "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
11042            ),
11043            "{err}"
11044        );
11045        assert!(err.contains("--delete issue-7"), "{err}");
11046        assert_eq!(
11047            theirs.trim(),
11048            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11049        );
11050        assert_eq!(
11051            WriteSummary {
11052                attempted: 1,
11053                failed: 1
11054            },
11055            repo.write_summary()
11056        );
11057    }
11058
11059    /// A lease that loses says which ref and why, not git's closing line.
11060    #[test]
11061    fn a_rejected_push_names_the_ref_it_rejected() {
11062        let stale = "To github.com:example/project.git\n \
11063                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
11064                     error: failed to push some refs to 'github.com:example/project.git'";
11065
11066        let err = push_error(
11067            &PushTarget::Origin,
11068            "issue-7",
11069            &crate::error::SparError::new(stale),
11070        )
11071        .to_string();
11072
11073        assert!(
11074            err.starts_with(
11075                "could not push to origin/issue-7. [rejected]        HEAD -> issue-7 (stale info)"
11076            ),
11077            "{err}"
11078        );
11079    }
11080
11081    #[test]
11082    fn a_fork_is_reached_by_whatever_reaches_origin() {
11083        let cases = [
11084            (
11085                "git@github.com:example/project.git",
11086                Some("git@github.com:someone/project.git"),
11087            ),
11088            // No user: ~/.ssh/config supplies it.
11089            (
11090                "github.com:example/project.git",
11091                Some("github.com:someone/project.git"),
11092            ),
11093            // A host alias, how one machine pushes as two accounts.
11094            (
11095                "git@github-work:example/project.git",
11096                Some("git@github-work:someone/project.git"),
11097            ),
11098            // SSH over 443, where 22 is blocked.
11099            (
11100                "ssh://git@ssh.github.com:443/example/project.git",
11101                Some("ssh://git@ssh.github.com:443/someone/project.git"),
11102            ),
11103            (
11104                "https://github.com/example/project",
11105                Some("https://github.com/someone/project"),
11106            ),
11107            (
11108                "https://ghe.example.com/scm/example/project.git/",
11109                Some("https://ghe.example.com/scm/someone/project.git"),
11110            ),
11111            (
11112                "git@host:/srv/example/project.git",
11113                Some("git@host:/srv/someone/project.git"),
11114            ),
11115            ("/srv/git/project.git", None),
11116            ("https://github.com/project", None),
11117            ("", None),
11118        ];
11119        for (origin, want) in cases {
11120            assert_eq!(
11121                want.map(str::to_string),
11122                fork_url(origin, "someone/project"),
11123                "{origin}"
11124            );
11125        }
11126    }
11127
11128    #[test]
11129    fn a_fork_found_or_made_is_reached_the_way_origin_is() {
11130        let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
11131            "ssh_url":"git@github.com:someone/project.git",
11132            "clone_url":"https://github.com/someone/project.git","fork":true}"#;
11133        let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
11134            "sshUrl":"git@github.com:someone/project.git",
11135            "url":"https://github.com/someone/project"}]"#;
11136
11137        let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
11138        assert_eq!("git@github-work:someone/project.git", fork.url);
11139        assert_eq!("someone", fork.owner);
11140        assert_eq!("someone/project", fork.full_name);
11141        let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
11142            .unwrap()
11143            .unwrap();
11144        assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
11145        assert_eq!(
11146            None,
11147            fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
11148        );
11149
11150        // An origin with no owner/repo path falls back to GitHub's URL, the
11151        // SSH one when origin looks like SSH.
11152        let fork = fork_from_api(made, "https://github.com/project").unwrap();
11153        assert_eq!("https://github.com/someone/project.git", fork.url);
11154        let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
11155        assert_eq!("git@github.com:someone/project.git", fork.url);
11156
11157        assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
11158        assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
11159    }
11160
11161    #[test]
11162    fn only_the_viewers_own_fork_can_be_pushed_to() {
11163        let pr = |owner: &str, name: &str| PrView {
11164            number: 9,
11165            url: String::new(),
11166            title: String::new(),
11167            body: String::new(),
11168            head_ref_name: "issue-7".into(),
11169            base_ref_name: "main".into(),
11170            state: "OPEN".into(),
11171            closing_issues_references: Vec::new(),
11172            is_cross_repository: true,
11173            head_repository_owner: Some(crate::model::Author {
11174                login: owner.into(),
11175            }),
11176            head_repository: Some(crate::model::HeadRepository { name: name.into() }),
11177        };
11178        let origin = "git@github.com:example/project.git";
11179
11180        assert_eq!(
11181            Some(PushTarget::Fork(Fork {
11182                owner: "Someone".into(),
11183                full_name: "Someone/project".into(),
11184                url: "git@github.com:Someone/project.git".into(),
11185            })),
11186            own_fork_target(&pr("Someone", "project"), "someone", origin)
11187        );
11188        assert_eq!(
11189            None,
11190            own_fork_target(&pr("other", "project"), "someone", origin)
11191        );
11192        assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
11193        assert_eq!(
11194            None,
11195            own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
11196        );
11197        let mut unnamed = pr("someone", "project");
11198        unnamed.head_repository_owner = None;
11199        assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
11200    }
11201
11202    /// `spar resume` and `spar checkin` on a pull request from the user's own
11203    /// fork check its branch out from the fork and push back there.
11204    #[cfg(unix)]
11205    #[test]
11206    fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
11207        let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
11208        test_git(
11209            &work,
11210            &[
11211                "push",
11212                "-q",
11213                fork.to_str().unwrap(),
11214                "issue-7:refs/heads/issue-7",
11215            ],
11216        );
11217        let published = test_git(&work, &["rev-parse", "issue-7"]);
11218        test_git(&work, &["checkout", "-q", "main"]);
11219        let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
11220        let pr = PrView {
11221            number: 31,
11222            url: String::new(),
11223            title: String::new(),
11224            body: String::new(),
11225            head_ref_name: "issue-7".into(),
11226            base_ref_name: "main".into(),
11227            state: "OPEN".into(),
11228            closing_issues_references: Vec::new(),
11229            is_cross_repository: true,
11230            head_repository_owner: None,
11231            head_repository: None,
11232        };
11233
11234        let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
11235
11236        assert_eq!("issue-7", branch);
11237        assert_eq!(
11238            published.trim(),
11239            test_git(&path, &["rev-parse", "HEAD"]).trim()
11240        );
11241        std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
11242        test_git(&path, &["commit", "-qam", "Answer a comment"]);
11243        repo.push_to(&path, &target, &branch, Some(published.trim()))
11244            .unwrap();
11245        assert_eq!(
11246            test_git(&path, &["rev-parse", "HEAD"]).trim(),
11247            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11248        );
11249        assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11250            .trim()
11251            .is_empty());
11252    }
11253
11254    #[cfg(unix)]
11255    #[test]
11256    fn with_forking_off_a_refused_push_says_why_and_how() {
11257        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
11258
11259        let err = repo
11260            .publish_new_branch(&work, "issue-7", "main")
11261            .unwrap_err()
11262            .to_string();
11263
11264        assert!(
11265            err.contains("Permission to example/project.git denied to someone."),
11266            "{err}"
11267        );
11268        assert!(err.contains("fork_when_denied = true"), "{err}");
11269        assert!(test_git(&fork, &["branch", "--list", "issue-7"])
11270            .trim()
11271            .is_empty());
11272        assert_eq!(
11273            WriteSummary {
11274                attempted: 1,
11275                failed: 1
11276            },
11277            repo.write_summary()
11278        );
11279    }
11280}
11281
11282#[cfg(test)]
11283mod comment_page_tests {
11284    use super::*;
11285
11286    #[test]
11287    fn a_single_merged_array_is_read() {
11288        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
11289        assert_eq!(2, pages.len());
11290        assert_eq!(Some(2), pages[1]["id"].as_i64());
11291    }
11292
11293    #[test]
11294    fn concatenated_pages_from_an_older_gh_are_read_too() {
11295        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
11296        assert_eq!(2, pages.len());
11297    }
11298
11299    /// A comment body containing a bracket pair used to split the payload into
11300    /// two invalid halves, so no state comment was found and a resume silently
11301    /// started from round one.
11302    #[test]
11303    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
11304        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
11305        let pages = parse_comment_pages(text);
11306        assert_eq!(2, pages.len(), "{pages:?}");
11307        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
11308    }
11309
11310    #[test]
11311    fn empty_output_is_no_comments_not_a_panic() {
11312        assert!(parse_comment_pages("").is_empty());
11313        assert!(parse_comment_pages("   ").is_empty());
11314        assert!(parse_comment_pages("[]").is_empty());
11315    }
11316
11317    #[test]
11318    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
11319        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
11320    }
11321
11322    #[test]
11323    fn a_write_postcheck_rejects_truncated_comment_pages() {
11324        let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
11325        assert!(
11326            error.to_string().contains("unexpected comment pages"),
11327            "{error}"
11328        );
11329    }
11330
11331    #[test]
11332    fn a_write_postcheck_rejects_empty_or_non_array_output() {
11333        assert!(try_parse_comment_pages("").is_err());
11334        assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
11335        assert!(try_parse_comment_pages("[]").is_ok());
11336    }
11337
11338    #[test]
11339    fn state_is_found_in_the_last_matching_comment() {
11340        let payload = |round: u32| {
11341            format!(
11342                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11343            )
11344        };
11345        let text = serde_json::to_string(&serde_json::json!([
11346            {"id": 1, "body": payload(1)},
11347            {"id": 2, "body": "looks good to me"},
11348            {"id": 3, "body": payload(5)},
11349        ]))
11350        .unwrap();
11351        let pages = parse_comment_pages(&text);
11352        let last = pages
11353            .iter()
11354            .rev()
11355            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
11356            .unwrap();
11357        assert_eq!(5, last.round);
11358    }
11359}
11360
11361#[cfg(test)]
11362mod linked_pr_tests {
11363    use super::*;
11364
11365    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
11366    /// It carries an id and a whole repository object per linked issue, and a
11367    /// parser that chokes on those reports "no pull request", which is the one
11368    /// answer that makes spar implement over the top of somebody's work.
11369    const REAL_PAYLOAD: &str = r#"[
11370      {"number":14252,"title":"fix: reject leading-dash branch names",
11371       "url":"https://github.com/cli/cli/pull/14252",
11372       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
11373         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11374         "url":"https://github.com/cli/cli/issues/14238"}]},
11375      {"number":14217,"title":"another change",
11376       "url":"https://github.com/cli/cli/pull/14217",
11377       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
11378         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11379         "url":"https://github.com/cli/cli/issues/9761"}]},
11380      {"number":14200,"title":"unlinked work",
11381       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
11382    ]"#;
11383
11384    #[test]
11385    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
11386        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
11387        assert_eq!(14252, pr.number);
11388        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
11389    }
11390
11391    #[test]
11392    fn the_right_pr_is_picked_out_of_several() {
11393        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
11394    }
11395
11396    #[test]
11397    fn an_issue_nobody_is_working_on_finds_nothing() {
11398        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
11399    }
11400
11401    #[test]
11402    fn an_unlinked_pr_is_never_matched() {
11403        // 14200 closes nothing, so no issue number should ever return it.
11404        for issue in [14200, 0, 1] {
11405            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
11406                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
11407            }
11408        }
11409    }
11410
11411    #[test]
11412    fn empty_or_broken_output_is_none_rather_than_a_panic() {
11413        assert!(find_linked_pr("", 1).is_none());
11414        assert!(find_linked_pr("[]", 1).is_none());
11415        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
11416        assert!(find_linked_pr("[{\"number\":", 1).is_none());
11417    }
11418
11419    /// "gh could not answer" and "nobody is working on it" are different
11420    /// answers, and only one of them means implement from scratch.
11421    ///
11422    /// The parse used to fold both into `None`, so an authentication failure or
11423    /// a rate limit read as an empty list and spar opened a second pull request
11424    /// for an issue somebody already had work open on.
11425    #[test]
11426    fn an_unreadable_list_is_not_an_empty_list() {
11427        assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
11428        assert!(linked_pr_rows("[{\"number\":").is_none());
11429        assert!(linked_pr_rows("").is_none());
11430
11431        let empty = linked_pr_rows("[]").expect("an empty list is an answer");
11432        assert!(empty.is_empty());
11433        assert!(pick_linked_pr(&empty, 14238).is_none());
11434
11435        let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
11436        assert_eq!(3, listed.len());
11437        assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
11438    }
11439
11440    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
11441    #[test]
11442    fn pr_view_reads_the_cross_repository_flag() {
11443        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
11444                       "baseRefName":"main","state":"OPEN",
11445                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
11446        let pr: PrView = serde_json::from_str(json).unwrap();
11447        assert!(pr.is_cross_repository);
11448        assert!(pr.is_open());
11449
11450        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
11451        assert!(
11452            !serde_json::from_str::<PrView>(&same_repo)
11453                .unwrap()
11454                .is_cross_repository
11455        );
11456    }
11457}
11458
11459#[cfg(test)]
11460mod min_number_tests {
11461    /// The floor is applied before the cap, which is the order that matters.
11462    /// spar takes the *lowest* numbered open items, so a repository with a tail
11463    /// of old issues would otherwise spend its whole run in the tail: the cap
11464    /// would be filled by the oldest items and the floor would never be
11465    /// reached. Filtering first is what makes the setting do anything.
11466    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
11467        let mut numbers: Vec<i64> = open.to_vec();
11468        numbers.sort_unstable();
11469        if min_number > 0 {
11470            numbers.retain(|n| *n >= min_number);
11471        }
11472        numbers.truncate(limit);
11473        numbers
11474    }
11475
11476    #[test]
11477    fn the_floor_is_applied_before_the_cap_not_after() {
11478        let open = [12, 13, 14, 480, 481, 482];
11479        assert_eq!(vec![480, 481], pick(&open, 2, 480));
11480        // Capping first would have returned the two oldest and then filtered
11481        // them all away, leaving nothing.
11482        assert!(!pick(&open, 2, 480).is_empty());
11483    }
11484
11485    #[test]
11486    fn no_floor_keeps_the_old_behaviour() {
11487        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
11488    }
11489
11490    #[test]
11491    fn the_floor_is_inclusive() {
11492        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
11493    }
11494
11495    #[test]
11496    fn a_floor_above_everything_open_yields_nothing() {
11497        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
11498    }
11499}
11500
11501#[cfg(test)]
11502mod review_record_tests {
11503    use super::*;
11504    use serde_json::json;
11505
11506    #[test]
11507    fn prune_state_knows_which_files_belong_to_a_pull_request() {
11508        assert_eq!(Some(12), state_file_number("pr-12.json"));
11509        assert_eq!(Some(12), state_file_number("review-12.json"));
11510        assert_eq!(Some(12), state_file_number("checkin-12.json"));
11511        for shared in [
11512            "spend.json",
11513            "implementors.json",
11514            "contested.json",
11515            "listen.json",
11516            "pr-.json",
11517            "pr-12.json.tmp",
11518            "pr-x.json",
11519        ] {
11520            assert_eq!(None, state_file_number(shared), "{shared}");
11521        }
11522    }
11523
11524    #[test]
11525    fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
11526        let reviews = vec![
11527            json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
11528            json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
11529        ];
11530        assert!(has_viewer_review(&reviews, "me", "the body", "aaa"));
11531        assert!(!has_viewer_review(&reviews, "me", "other", "aaa"));
11532        assert!(!has_viewer_review(&reviews, "alice", "the body", "aaa"));
11533    }
11534
11535    /// A clean review says the same thing on every commit. Taking the approval
11536    /// of an older commit for this one left the new head unapproved while the
11537    /// record said it was.
11538    #[test]
11539    fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
11540        let older = vec![
11541            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
11542        ];
11543        assert!(!has_viewer_review(&older, "me", "the body", "bbb"));
11544        let dismissed = vec![
11545            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
11546        ];
11547        assert!(!has_viewer_review(&dismissed, "me", "the body", "bbb"));
11548    }
11549
11550    #[test]
11551    fn a_response_splits_into_status_headers_and_body() {
11552        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";
11553        let resp = parse_api_response(text).expect("a response");
11554        assert_eq!(200, resp.status);
11555        assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
11556        assert_eq!(
11557            Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
11558            resp.headers.get("last-modified")
11559        );
11560        assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
11561
11562        let not_modified =
11563            parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
11564        assert_eq!(304, not_modified.status);
11565        assert!(not_modified.body.is_empty());
11566
11567        assert_eq!(None, parse_api_response(""));
11568        assert_eq!(None, parse_api_response("gh: command not found\n"));
11569    }
11570
11571    #[test]
11572    fn review_requests_are_read_off_the_timeline_with_who_and_when() {
11573        let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
11574            {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
11575            {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
11576            {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
11577            {}
11578        ]}}}}})
11579        .to_string();
11580        let made = parse_review_requests(&text).expect("requests");
11581        assert_eq!(2, made.len());
11582        assert_eq!("2026-10-20T10:00:00Z", made[0].at);
11583        assert_eq!("viewer", made[0].who.login);
11584        assert!(made[0].who.slug.is_empty());
11585        assert_eq!("2026-10-20T11:00:00Z", made[1].at);
11586        assert!(made[1].who.login.is_empty());
11587        assert_eq!("example/ours", made[1].who.slug);
11588
11589        let none =
11590            json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
11591        assert_eq!(
11592            Some(0),
11593            parse_review_requests(&none.to_string()).map(|m| m.len())
11594        );
11595        assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
11596        assert!(parse_review_requests("gh: not json").is_none());
11597    }
11598
11599    #[test]
11600    fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
11601        let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
11602        let error = crate::error::SparError::call_failed("gh: boom");
11603        assert!(
11604            reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(landed)).is_ok()
11605        );
11606
11607        let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
11608        let err = reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(missing))
11609            .unwrap_err();
11610        assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
11611
11612        let unreadable = Err(crate::error::SparError::new("no network"));
11613        let err = reconcile_review_post(5, "the body", "me", "aaa", error, unreadable).unwrap_err();
11614        assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
11615        assert!(err.to_string().contains("could not be verified"), "{err}");
11616    }
11617    #[test]
11618    fn a_review_with_line_comments_names_the_new_side_and_a_range_only_when_it_has_one() {
11619        let comments = vec![
11620            InlineComment {
11621                path: "src/a.rs".into(),
11622                line: 12,
11623                start_line: None,
11624                body: "one".into(),
11625            },
11626            InlineComment {
11627                path: "src/b.rs".into(),
11628                line: 9,
11629                start_line: Some(4),
11630                body: "two".into(),
11631            },
11632        ];
11633        assert_eq!(
11634            json!({
11635                "commit_id": "abc",
11636                "event": "COMMENT",
11637                "body": "the body",
11638                "comments": [
11639                    {"path": "src/a.rs", "line": 12, "side": "RIGHT", "body": "one"},
11640                    {"path": "src/b.rs", "line": 9, "side": "RIGHT", "start_line": 4,
11641                     "start_side": "RIGHT", "body": "two"},
11642                ],
11643            }),
11644            review_payload("abc", "COMMENT", "the body", &comments)
11645        );
11646    }
11647
11648    #[test]
11649    fn the_new_side_of_each_hunk_is_read_and_its_lines_are_never_taken_for_headers() {
11650        let diff = r#"diff --git a/src/a.rs b/src/a.rs
11651index 1111111..2222222 100644
11652--- a/src/a.rs
11653+++ b/src/a.rs
11654@@ -1,3 +1,4 @@ fn x
11655 one
11656+++ an added line that looks like a header
11657 two
11658 three
11659@@ -20 +21,2 @@
11660-old
11661+new
11662+more
11663@@ -30,1 +32,0 @@
11664-gone
11665diff --git a/gone.rs b/gone.rs
11666deleted file mode 100644
11667--- a/gone.rs
11668+++ /dev/null
11669@@ -1,2 +0,0 @@
11670-a
11671-b
11672diff --git a/old.rs b/renamed.rs
11673similarity index 90%
11674rename from old.rs
11675rename to renamed.rs
11676--- a/old.rs
11677+++ b/renamed.rs
11678@@ -5,0 +6,1 @@
11679+inserted
11680diff --git a/with space.rs b/with space.rs
11681--- a/with space.rs	
11682+++ b/with space.rs	
11683@@ -2 +2 @@
11684-x
11685+y
11686diff --git "a/sp\tace.rs" "b/sp\tace.rs"
11687--- "a/sp\tace.rs"
11688+++ "b/sp\tace.rs"
11689@@ -1 +1 @@
11690-x
11691+y
11692"#;
11693        let hunks = new_side_hunks(diff);
11694        assert_eq!(
11695            Hunks::from([
11696                ("renamed.rs".to_string(), vec![(6, 6)]),
11697                ("src/a.rs".to_string(), vec![(1, 4), (21, 22)]),
11698                ("with space.rs".to_string(), vec![(2, 2)]),
11699            ]),
11700            hunks
11701        );
11702    }
11703
11704    #[test]
11705    fn only_a_refusal_is_final_enough_to_send_a_review_again() {
11706        let error = |text: &str| crate::error::SparError::new(text);
11707        assert!(refused(&error(
11708            "command failed (1): gh api\ngh: Validation Failed (HTTP 422)"
11709        )));
11710        assert!(!refused(&error("gh: Bad Gateway (HTTP 502)")));
11711        assert!(!refused(&error("gh: HTTP 504: Gateway Timeout")));
11712        assert!(!refused(&error("command timed out after 300s")));
11713    }
11714
11715    #[test]
11716    fn a_hunk_header_with_its_counts_left_out_counts_one() {
11717        assert_eq!(Some(((20, 1), (21, 2))), hunk_header("@@ -20 +21,2 @@"));
11718        assert_eq!(Some(((1, 3), (1, 1))), hunk_header("@@ -1,3 +1 @@ fn x"));
11719        assert_eq!(None, hunk_header("@@@ -1,3 -1,3 +1,4 @@@"));
11720        assert_eq!(None, hunk_header(" @@ -1 +1 @@"));
11721    }
11722}