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::{CheckCommand, 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/// A read checkout is named for the process that made it, then a serial, so
67/// the sweep can tell one still in use from one a killed run left behind.
68const READ_CHECKOUT_PREFIX: &str = "read-";
69
70/// Files SPAR writes into a worktree itself, which no agent ever authors.
71pub(crate) const RECOVERY_MARKER_PREFIX: &str = ".spar-recovery-needed-";
72pub(crate) const EDITED_GIT_MARKER: &str = ".spar-edited-git-marker";
73
74/// What SPAR is allowed to throw away when it decides a checkout can go.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub(crate) enum Disposable {
77    /// Recognized build output, and nothing else. Somebody may have put the
78    /// rest there and want it back.
79    BuildOutput,
80    /// Every ignored file, and SPAR's own markers. Only rebuilding a review
81    /// checkout qualifies, and only because the next statement replaces it with
82    /// a fresh checkout of the same ref: what is there came from an earlier
83    /// `spar review` of the same pull request, and `git worktree add` would
84    /// write over it regardless. Refusing instead left the command unusable on
85    /// any project whose build writes outside `target/`, since a single
86    /// lockfile beside a generator's manifest blocked every later run.
87    ///
88    /// A read checkout qualifies too, once it is shown unchanged and holds no
89    /// marker. Nobody works in it, and the only calls that ran there were
90    /// allowed to leave ignored files and nothing else.
91    ReviewOutput,
92}
93
94/// Whether `path` is one of the files SPAR leaves in a worktree root.
95///
96/// A marker is evidence for a person, not something a call did to the tree, and
97/// two reviewers share one checkout: the marker written when the first one's
98/// answer was refused is otherwise a brand new untracked file in the snapshot
99/// the second one is compared against, so one refusal took the other review
100/// down with it.
101pub(crate) fn is_spar_marker(path: &Path) -> bool {
102    let mut components = path.components();
103    let Some(std::path::Component::Normal(name)) = components.next() else {
104        return false;
105    };
106    if components.next().is_some() {
107        return false;
108    }
109    let Some(name) = name.to_str() else {
110        return false;
111    };
112    name == EDITED_GIT_MARKER || name.starts_with(RECOVERY_MARKER_PREFIX)
113}
114
115/// Whether `path` is somewhere SPAR writes in the repository it is driving.
116///
117/// The markers are one part of it. The other is the run state under `.spar/`,
118/// which SPAR rewrites while a call it started is still running: recording the
119/// branches it created during a parallel triage is enough to make both agents
120/// look like they changed the tree they were told only to read. SPAR's own
121/// bookkeeping is not evidence about an agent, so it never enters the snapshot
122/// a call is compared against.
123///
124/// `.spar-worktrees/` is deliberately not here. A resident worktree enters the
125/// snapshot as one ignored entry so that another run's churn inside it does not
126/// disturb this baseline, while deleting the whole checkout still does.
127pub(crate) fn is_spar_own_path(path: &Path) -> bool {
128    if is_spar_marker(path) {
129        return true;
130    }
131    let Some(std::path::Component::Normal(name)) = path.components().next() else {
132        return false;
133    };
134    name == OsStr::new(STATE_DIR)
135}
136
137/// How many names one part of a split may be tried on before giving up. High
138/// enough that nobody reaches it by splitting the same pull request again, low
139/// enough that a repository where every name is taken says so rather than
140/// looping.
141const SPLIT_SLOTS: u32 = 20;
142
143/// The longest `[loop] setup` may run, as long as the check gets. An install
144/// that has not finished in half an hour is stuck rather than slow.
145const SETUP_TIMEOUT_SECS: u64 = 1800;
146
147#[derive(Debug, Clone)]
148pub struct SplitPushError {
149    message: String,
150    retain_worktree: bool,
151}
152
153impl SplitPushError {
154    pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
155        Self {
156            message: message.into(),
157            retain_worktree,
158        }
159    }
160
161    pub fn retain_worktree(&self) -> bool {
162        self.retain_worktree
163    }
164}
165
166impl std::fmt::Display for SplitPushError {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        f.write_str(&self.message)
169    }
170}
171
172impl std::error::Error for SplitPushError {}
173
174/// The branch and worktree name for one part, on its `attempt`th name.
175///
176/// The unsuffixed name first, so the ordinary case reads as `split-12-1` and
177/// only a repeat split carries a suffix.
178fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
179    match attempt {
180        1 => format!("split-{parent}-{index}"),
181        n => format!("split-{parent}-{index}-{n}"),
182    }
183}
184
185/// Where a branch spar opened a pull request from is published.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub enum PushTarget {
188    Origin,
189    /// The authenticated user's fork, for a repository origin would not let
190    /// them push to.
191    Fork(Fork),
192}
193
194/// A fork of this repository that spar publishes branches to.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct Fork {
197    /// The account that owns it, which `gh pr create --head` needs in front of
198    /// the branch name.
199    pub owner: String,
200    /// `owner/name`, for saying where a branch went.
201    pub full_name: String,
202    /// The SSH or HTTPS URL, whichever origin uses, pushed to directly so the
203    /// checkout's remotes are left as they were.
204    pub url: String,
205}
206
207impl PushTarget {
208    /// What `gh pr create --head` takes for a branch published here.
209    pub fn head(&self, branch: &str) -> String {
210        match self {
211            PushTarget::Origin => branch.to_string(),
212            PushTarget::Fork(fork) => format!("{}:{branch}", fork.owner),
213        }
214    }
215
216    fn remote(&self) -> &str {
217        match self {
218            PushTarget::Origin => "origin",
219            PushTarget::Fork(fork) => &fork.url,
220        }
221    }
222
223    fn describe(&self, branch: &str) -> String {
224        match self {
225            PushTarget::Origin => format!("origin/{branch}"),
226            PushTarget::Fork(fork) => format!("{branch} on the fork {}", fork.full_name),
227        }
228    }
229}
230
231/// A throwaway checkout of HEAD for calls that are only allowed to read.
232///
233/// The shared checkout is where a person, their editor, and any other agent
234/// work, and a read-only call is judged on what changed while it ran. Run
235/// there, an edit somebody else made in those minutes counted as the call's: a
236/// draft that had only ever read was thrown away because another agent bumped
237/// a dependency beside it. Nothing works in this checkout but the calls spar
238/// starts in it, so whatever changes in it is theirs.
239///
240/// It holds what HEAD commits, after `[loop] setup`, and nothing uncommitted.
241/// Dropping it removes it, unless something in it changed that a read-only
242/// call was not allowed to change, in which case it is kept for recovery.
243pub struct ReadCheckout<'a> {
244    repo: &'a Repo,
245    path: PathBuf,
246    /// What the checkout held once it was set up, or `None` when the calls run
247    /// in the shared checkout after all.
248    checkpoint: Option<WorktreeCheckpoint>,
249}
250
251impl ReadCheckout<'_> {
252    pub fn path(&self) -> &Path {
253        &self.path
254    }
255}
256
257impl Drop for ReadCheckout<'_> {
258    fn drop(&mut self) {
259        if let Some(checkpoint) = self.checkpoint.take() {
260            self.repo.release_read_checkout(&self.path, &checkpoint);
261        }
262    }
263}
264
265#[derive(Debug)]
266pub struct Repo {
267    root: PathBuf,
268    pub style: Style,
269    pub branch_prefix: String,
270    pub state_store: StateStore,
271    pub followups: Followups,
272    pub drafts: Drafts,
273    /// Publish a new branch from a fork when origin refuses the push.
274    pub fork_when_denied: bool,
275    /// What `[loop] setup` runs in each worktree spar creates.
276    setup: CheckCommand,
277    /// Whether read-only calls get a checkout of their own, which is what
278    /// `[loop] worktrees` says for every other call.
279    isolate_reads: bool,
280    /// Leave each read checkout in place after its calls, for inspection.
281    keep_worktrees: bool,
282    /// The fork new branches go to once origin has refused one, found at most
283    /// once.
284    fork: OnceLock<Fork>,
285    /// The login `gh` is authenticated as, asked at most once.
286    ///
287    /// `OnceLock` rather than `OnceCell` because `&Repo` crosses a
288    /// `std::thread::scope` whenever both agents are asked at the same time,
289    /// and only `OnceLock` is `Sync`.
290    viewer: OnceLock<String>,
291    /// Highest persisted checkpoint observed for each pull request.
292    ///
293    /// Kept in memory so a transient state read cannot reset the sequence
294    /// after a resume already loaded a newer checkpoint.
295    checkpoints: Mutex<BTreeMap<i64, u64>>,
296    writes: WriteStats,
297}
298
299#[derive(Debug, Default)]
300struct WriteStats {
301    attempted: AtomicUsize,
302    failed: AtomicUsize,
303}
304
305#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
306pub(crate) struct WriteSummary {
307    pub(crate) attempted: usize,
308    pub(crate) failed: usize,
309}
310
311impl WriteSummary {
312    pub(crate) fn succeeded(self) -> usize {
313        self.attempted.saturating_sub(self.failed)
314    }
315}
316
317/// Ignored, untracked paths present before an editing call starts.
318///
319/// Existing build artifacts are deliberately part of the baseline. Callers can
320/// therefore distinguish them from an ignored file the editing call created
321/// and avoid deleting the latter as if no work had happened.
322#[derive(Debug, Clone)]
323pub(crate) struct WorktreeBaseline {
324    attributes: AttributeState,
325    ignored_untracked: IgnoredState,
326    git_state: GitState,
327}
328
329/// The recorded Git state of a worktree that must remain read only.
330///
331/// Read-only agent calls are still ordinary processes. Capturing the complete
332/// working state lets callers refuse to publish their answer or delete the
333/// checkout if a call writes despite its instructions.
334#[derive(Debug, Clone)]
335pub(crate) struct WorktreeCheckpoint {
336    path: PathBuf,
337    attributes: AttributeState,
338    git_state: GitState,
339    ignored_untracked: IgnoredState,
340}
341
342#[derive(Debug, Clone, Default, PartialEq, Eq)]
343pub(crate) struct AttributeState {
344    files: BTreeMap<PathBuf, [u8; 32]>,
345}
346
347/// Exact paths and fingerprints for every untracked file, plus ignored paths.
348///
349/// Paths stay as operating-system strings so a non-UTF-8 filename cannot be
350/// merged with another path by lossy command-output conversion.
351#[derive(Debug, Clone, Default, PartialEq, Eq)]
352pub(crate) struct IgnoredState {
353    files: BTreeMap<PathBuf, UntrackedFile>,
354    ignored: BTreeSet<PathBuf>,
355}
356
357/// A bounded-cost fingerprint for an untracked filesystem entry.
358///
359/// Content hashing every ignored compiler artifact made each checkpoint read
360/// gigabytes. File identity, type, size, timestamps, and mode detect ordinary
361/// writes without rereading build output. Unix change time also changes when a
362/// writer restores the modification time.
363#[derive(Debug, Clone, PartialEq, Eq)]
364struct UntrackedFile {
365    kind: u8,
366    len: u64,
367    modified: Option<std::time::SystemTime>,
368    created: Option<std::time::SystemTime>,
369    readonly: bool,
370    symlink_target: Option<Vec<u8>>,
371    #[cfg(unix)]
372    device: u64,
373    #[cfg(unix)]
374    inode: u64,
375    #[cfg(unix)]
376    mode: u32,
377    #[cfg(unix)]
378    change_seconds: i64,
379    #[cfg(unix)]
380    change_nanoseconds: i64,
381}
382
383#[derive(Debug, Clone, Default, PartialEq, Eq)]
384pub(crate) struct GitState {
385    repositories: BTreeMap<PathBuf, RepositoryState>,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
389struct RepositoryState {
390    head: String,
391    unsafe_index_flags: Vec<u8>,
392    tracked: BTreeMap<PathBuf, TrackedEntry>,
393    gitlinks: BTreeMap<PathBuf, String>,
394}
395
396#[derive(Debug, Clone, PartialEq, Eq)]
397struct TrackedEntry {
398    index_mode: String,
399    index_oid: String,
400    worktree: Option<WorktreeFile>,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq)]
404struct WorktreeFile {
405    mode: String,
406    #[cfg(unix)]
407    permissions: u32,
408    raw_oid: String,
409    fingerprint: [u8; 32],
410    content: [u8; 32],
411}
412
413struct Gitlink {
414    path: PathBuf,
415    oid: String,
416}
417
418struct IndexEntry {
419    path: PathBuf,
420    mode: String,
421    oid: String,
422}
423
424impl IgnoredState {
425    fn is_ignored(&self, path: &Path) -> bool {
426        self.ignored.contains(path)
427    }
428
429    fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
430        let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
431        paths.extend(after.files.keys().cloned());
432        paths
433            .into_iter()
434            .filter(|path| {
435                self.files.get(path) != after.files.get(path)
436                    || self.is_ignored(path) != after.is_ignored(path)
437            })
438            .collect()
439    }
440
441    fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
442        self.files
443            .iter()
444            .filter(|(path, state)| after.files.get(*path) != Some(*state))
445            .map(|(path, _)| path.clone())
446            .collect()
447    }
448
449    pub(crate) fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
450        after
451            .files
452            .keys()
453            .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
454            .cloned()
455            .collect()
456    }
457
458    /// Whether anything moved here that is not recognized build or cache
459    /// output.
460    ///
461    /// A read-only call is held to its word by comparing the worktree before
462    /// and after. Verifying a finding usually means building the project and
463    /// running its tests, which rewrites that output, and holding a reviewer to
464    /// a byte-identical `dist/` threw away the review it had just done. What
465    /// the call was asked to judge is the tracked tree, and that is compared as
466    /// strictly as ever, along with every other untracked file.
467    pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
468        let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
469        paths.extend(after.files.keys());
470        paths.into_iter().any(|path| {
471            if self.files.get(path) == after.files.get(path)
472                && self.is_ignored(path) == after.is_ignored(path)
473            {
474                return false;
475            }
476            !self.generated_move(after, path)
477        })
478    }
479
480    /// What a read-only call left behind, split by whether it condemns the
481    /// answer.
482    ///
483    /// `changed_beyond_generated` answers this from a list of directory names,
484    /// which is a guess about where a build writes. The repository already
485    /// states where it writes, in its own ignore rules, and a review died over
486    /// the gap: a reviewer ran the project's binding generator to check a
487    /// finding, that wrote a lockfile beside its own manifest rather than under
488    /// `target/`, and both agents lost an hour of work to a path Git had been
489    /// told to disown.
490    ///
491    /// Forgiving only the creation of such a path was not enough, because the
492    /// call is not the only writer. A read-only call runs for minutes in the
493    /// primary checkout, which is a live desk: Finder rewrites `.DS_Store`, an
494    /// editor rewrites its own state, and a triage that asks two agents at once
495    /// compares both of them against that same directory. One ambient write
496    /// discarded two good answers and ended the run with nothing scheduled. So
497    /// a path the project ignores is out of scope however it moved, and what
498    /// remains fatal is what the call was actually asked about: the tracked
499    /// tree, and any ordinary untracked file, because a reviewer was told to
500    /// keep its scratch out of the worktree.
501    ///
502    /// Nothing is silent. A tolerated path that is not recognized build output
503    /// is named by the caller, so an agent that rewrote a person's `.env` is
504    /// reported rather than hidden. That is the trade this makes: the answer of
505    /// a call that never commits is worth more than the guarantee, and the
506    /// warning carries what the guarantee used to.
507    pub(crate) fn read_only_leavings(&self, after: &Self) -> ReadOnlyLeavings {
508        let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
509        paths.extend(after.files.keys());
510        let mut leavings = ReadOnlyLeavings::default();
511        for path in paths {
512            if self.files.get(path) == after.files.get(path)
513                && self.is_ignored(path) == after.is_ignored(path)
514            {
515                continue;
516            }
517            if !self.ignored_file_move(after, path) {
518                leavings.condemning.push(path.clone());
519            } else if !is_generated_artifact(path) {
520                leavings.tolerated.push(path.clone());
521            }
522        }
523        leavings
524    }
525
526    /// Whether what happened at `path` between these two states is a build or
527    /// cache directory doing its job, rather than a change a person would miss.
528    ///
529    /// Removal counts, on either side. A compiler that keeps an incremental
530    /// cache does not only add and rewrite: it finalizes a new session
531    /// directory and deletes the one it superseded, lock file and all. A rule
532    /// that forgave creation and rewriting but not removal therefore passed the
533    /// first build in a fresh worktree and failed the second one, which is the
534    /// build a reviewer runs to check a finding. Two of these rules disagreeing
535    /// about the same directory is what cost the run: the read-only check
536    /// forgave the deleted session and the commit-side check called it
537    /// unrepresentable, so the work was already on a pull request when the run
538    /// ended. There is one rule now, and this is it.
539    fn generated_move(&self, after: &Self, path: &Path) -> bool {
540        is_generated_artifact(path) && self.disposable_move(after, path)
541    }
542
543    /// Successful edits and read-only calls share an ignore policy. Spar's
544    /// resident worktrees belong to other runs, not to this call's output.
545    /// Cleanup separately preserves ignored data, including dependency clones.
546    fn ignored_file_move(&self, after: &Self, path: &Path) -> bool {
547        if path.starts_with(WORKTREE_DIR)
548            && self.files.get(path).is_some_and(|file| file.kind == 3)
549            && self.files.get(path) != after.files.get(path)
550        {
551            return false;
552        }
553        self.disposable_move(after, path)
554    }
555
556    /// Whether neither state has anything at `path` worth keeping, so whatever
557    /// happened there took nothing with it.
558    fn disposable_move(&self, after: &Self, path: &Path) -> bool {
559        self.disposable(path) && after.disposable(path)
560    }
561
562    /// Whether this state has nothing at `path` worth keeping: either the path
563    /// is not there at all, or it is there as an ignored file.
564    fn disposable(&self, path: &Path) -> bool {
565        !self.files.contains_key(path) || self.is_ignored(path)
566    }
567}
568
569impl GitState {
570    /// What moved between two states, said so a person can act on it.
571    ///
572    /// The guards that compare these used to report only that the worktree had
573    /// changed, which left the one question worth answering, which file, to a
574    /// `git status` run by hand against a tree that had moved on since.
575    pub(crate) fn describe_difference(&self, after: &Self) -> Option<String> {
576        let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
577        prefixes.extend(after.repositories.keys());
578        let mut notes: Vec<String> = Vec::new();
579        let mut paths: Vec<PathBuf> = Vec::new();
580        for prefix in prefixes {
581            let label = describe_repository(prefix);
582            match (
583                self.repositories.get(prefix),
584                after.repositories.get(prefix),
585            ) {
586                (None, Some(_)) => notes.push(format!("a repository appeared at {label}")),
587                (Some(_), None) => notes.push(format!("the repository at {label} is gone")),
588                (Some(before), Some(current)) => {
589                    if before.head != current.head {
590                        notes.push(format!("HEAD moved in {label}"));
591                    }
592                    if before.unsafe_index_flags != current.unsafe_index_flags {
593                        notes.push(format!("index flags changed in {label}"));
594                    }
595                    paths.extend(
596                        differing_keys(&before.gitlinks, &current.gitlinks)
597                            .chain(differing_keys(&before.tracked, &current.tracked))
598                            .map(|path| prefix.join(path)),
599                    );
600                }
601                (None, None) => {}
602            }
603        }
604        if !paths.is_empty() {
605            paths.sort();
606            paths.dedup();
607            notes.push(format!("tracked file(s) changed: {}", list_paths(&paths)));
608        }
609        (!notes.is_empty()).then(|| notes.join("; "))
610    }
611
612    /// The tracked half of what a read-only call left behind, with a checkout
613    /// that moved under the call reported rather than held against it.
614    pub(crate) fn read_only_difference(&self, after: &Self, cwd: &Path) -> Option<String> {
615        let difference = self.describe_difference(after)?;
616        let Some(moved) = self.moved_wholesale(after) else {
617            return Some(difference);
618        };
619        // Once, however many calls were reading when it happened: two triage
620        // agents share the checkout and would otherwise both report the move.
621        if said_once("moved", cwd, &moved) {
622            logwarn!(
623                "the checkout at {} moved while a read-only call was reading it: {moved}. The \
624                 answer was kept, and it describes the tree as it stood before the move.",
625                cwd.display()
626            );
627        }
628        None
629    }
630
631    /// Whether every difference between these two states is the checkout moving
632    /// as a whole rather than the call writing into it.
633    ///
634    /// The primary checkout is a live desk and a read-only call reads it for
635    /// minutes. A person or their editor pulls, the branch fast forwards, and
636    /// every file that arrived with it now differs from the snapshot taken
637    /// before the call. Holding the call to that snapshot threw away two good
638    /// triage answers and ended a run with nothing scheduled, over a change the
639    /// call had no part in.
640    ///
641    /// The index tells the two apart. A call that wrote leaves a path
642    /// disagreeing with the index, staged or unstaged, and leaves HEAD where it
643    /// was. A checkout that moved rewrote the index and the worktree together,
644    /// so every path it touched agrees with the index, and it moved HEAD to say
645    /// so. Anything else, a tracked file that changed while HEAD stood still
646    /// most of all, is the call's to answer for.
647    fn moved_wholesale(&self, after: &Self) -> Option<String> {
648        let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
649        prefixes.extend(after.repositories.keys());
650        let mut notes: Vec<String> = Vec::new();
651        for prefix in prefixes {
652            // A repository that appeared or vanished is not a checkout moving.
653            let before = self.repositories.get(prefix)?;
654            let current = after.repositories.get(prefix)?;
655            if before.unsafe_index_flags != current.unsafe_index_flags
656                || before.gitlinks != current.gitlinks
657            {
658                return None;
659            }
660            let moved: Vec<&PathBuf> = differing_keys(&before.tracked, &current.tracked).collect();
661            if before.head == current.head {
662                if moved.is_empty() {
663                    continue;
664                }
665                return None;
666            }
667            if !moved.iter().all(|path| {
668                checked_out(before.tracked.get(*path)) && checked_out(current.tracked.get(*path))
669            }) {
670                return None;
671            }
672            let label = describe_repository(prefix);
673            notes.push(match moved.len() {
674                0 => format!("HEAD moved in {label}"),
675                count => format!("HEAD moved in {label}, carrying {count} tracked file(s)"),
676            });
677        }
678        (!notes.is_empty()).then(|| notes.join("; "))
679    }
680}
681
682/// Whether a tracked path holds exactly what the index says it holds, so
683/// nothing there is staged or left unstaged.
684///
685/// A path the index no longer lists counts as checked out: only an index
686/// operation drops one, and a file left in the worktree after that is an
687/// ordinary untracked file, which the untracked half of the same check has.
688fn checked_out(entry: Option<&TrackedEntry>) -> bool {
689    entry.is_none_or(|entry| {
690        entry
691            .worktree
692            .as_ref()
693            .is_some_and(|file| file.raw_oid == entry.index_oid && file.mode == entry.index_mode)
694    })
695}
696
697/// Say what a call left among paths the project ignores, once per run for a
698/// given set of them.
699///
700/// The project's own ignore rules put these outside what the call was asked
701/// about, so this is a note about the desk the call ran on rather than a
702/// finding about the call. Said as a warning on every call, it became the
703/// loudest thing in the log of every run in a checkout with an editor open in
704/// it: `.DS_Store` twice per triage, and again for each review. It still names
705/// the paths, so an agent that rewrote a person's `.env` is reported rather
706/// than hidden.
707pub(crate) fn note_ignored_leavings(cwd: &Path, what: &str, paths: &[PathBuf]) {
708    if paths.is_empty() {
709        return;
710    }
711    let named = list_paths(paths);
712    if !said_once("ignored", cwd, &named) {
713        return;
714    }
715    logdim!(
716        "a read-only {what} left {} ignored file(s) changed in {}: {named}. It was kept; the \
717         project ignores these paths.",
718        paths.len(),
719        cwd.display()
720    );
721}
722
723/// Whether this run has yet to say this about this directory.
724///
725/// A run reads the same checkout dozens of times, and the ambient facts about
726/// it, an editor's own files moving and a branch advancing under the reader,
727/// are true for every one of those calls. Said once they are context; said
728/// each time they are the log.
729fn said_once(topic: &str, cwd: &Path, detail: &str) -> bool {
730    static SAID: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
731    SAID.lock()
732        .unwrap_or_else(std::sync::PoisonError::into_inner)
733        .insert(format!("{topic}\0{}\0{detail}", cwd.display()))
734}
735
736/// Name a repository inside a state for a report. The root carries an empty
737/// prefix, and calling that "" helps nobody.
738fn describe_repository(prefix: &Path) -> String {
739    if prefix.as_os_str().is_empty() {
740        "the worktree".to_string()
741    } else {
742        format!("the submodule at {:?}", prefix.as_os_str())
743    }
744}
745
746/// The keys whose values differ between two maps, present on either side.
747fn differing_keys<'a, V: PartialEq>(
748    before: &'a BTreeMap<PathBuf, V>,
749    after: &'a BTreeMap<PathBuf, V>,
750) -> impl Iterator<Item = &'a PathBuf> {
751    let mut keys: BTreeSet<&'a PathBuf> = before.keys().collect();
752    keys.extend(after.keys());
753    keys.into_iter()
754        .filter(move |key| before.get(*key) != after.get(*key))
755}
756
757/// What a read-only call left in the untracked and ignored tree.
758///
759/// Split rather than reduced to a yes or no, because the two halves are
760/// answered differently: one ends the call, the other is said out loud and the
761/// answer is kept.
762#[derive(Default)]
763pub(crate) struct ReadOnlyLeavings {
764    /// Disposable on both sides, so out of scope for a call that only read.
765    /// Recognized build output is left out: rebuilding is what a reviewer was
766    /// asked to do, and nothing reports it today.
767    pub(crate) tolerated: Vec<PathBuf>,
768    /// Everything else. The answer does not survive these.
769    pub(crate) condemning: Vec<PathBuf>,
770}
771
772/// Put two accounts of what moved into one sentence, keeping whichever of them
773/// there is. Both guards that report a changed worktree ask the tracked tree
774/// and the untracked tree separately, and either can be the only answer.
775pub(crate) fn join_reasons(first: Option<String>, second: Option<String>) -> Option<String> {
776    match (first, second) {
777        (None, None) => None,
778        (Some(one), None) | (None, Some(one)) => Some(one),
779        (Some(first), Some(second)) => Some(format!("{first}; {second}")),
780    }
781}
782
783/// Name some paths for a person, without printing a build directory at them.
784///
785/// Every report of a path that stopped or was let through a call goes through
786/// this, so an error and the warning beside it read alike.
787pub(crate) fn list_paths(paths: &[PathBuf]) -> String {
788    const SHOWN: usize = 5;
789    let mut listed = paths
790        .iter()
791        .take(SHOWN)
792        .map(|path| format!("{:?}", path.as_os_str()))
793        .collect::<Vec<_>>()
794        .join(", ");
795    if paths.len() > SHOWN {
796        listed.push_str(&format!(", and {} more", paths.len() - SHOWN));
797    }
798    listed
799}
800
801/// Ignored files one commit attempt let through, gathered for a single report.
802///
803/// The checks that allow it run more than once per attempt, before staging,
804/// after staging, and again after the commit, because the tree could have moved
805/// under any of them. Reporting from inside each check said the same thing
806/// about the same files two and three times over.
807#[derive(Default)]
808struct IgnoredArtifacts {
809    new_paths: BTreeSet<PathBuf>,
810    changed_paths: BTreeSet<PathBuf>,
811    removed_paths: BTreeSet<PathBuf>,
812}
813
814impl IgnoredArtifacts {
815    fn left(&mut self, allowed: AllowedArtifacts) {
816        self.new_paths.extend(allowed.present);
817        self.removed_paths.extend(allowed.removed);
818    }
819
820    fn changed(&mut self, allowed: AllowedArtifacts) {
821        self.changed_paths.extend(allowed.present);
822        self.removed_paths.extend(allowed.removed);
823    }
824
825    /// Report paths without claiming ignored files are safe to delete.
826    fn report(&self, cwd: &Path) {
827        let paths: BTreeSet<_> = self
828            .new_paths
829            .iter()
830            .chain(&self.changed_paths)
831            .chain(&self.removed_paths)
832            .cloned()
833            .collect();
834        if !paths.is_empty() {
835            logdim!(
836                "the editing call changed {} ignored file(s) in {}: {}. They are outside \
837                 the commit; cleanup checks them separately.",
838                paths.len(),
839                cwd.display(),
840                list_paths(&paths.into_iter().collect::<Vec<_>>())
841            );
842        }
843    }
844}
845
846/// Ignored files a check let through, split by whether each one
847/// is still on disk, so the report can say what happened rather than call a
848/// deleted incremental session a file that was left behind.
849#[derive(Default)]
850struct AllowedArtifacts {
851    present: Vec<PathBuf>,
852    removed: Vec<PathBuf>,
853}
854
855impl AllowedArtifacts {
856    fn split(paths: Vec<PathBuf>, after: &IgnoredState) -> Self {
857        let (present, removed) = paths
858            .into_iter()
859            .partition(|path| after.files.contains_key(path));
860        Self { present, removed }
861    }
862}
863
864/// Generated directories that test and build commands routinely create.
865///
866/// Files under these directories are never committed, and they do not keep a
867/// worktree that is otherwise finished, so running the requested tests neither
868/// stops the tracked change reaching review nor leaves a checkout behind.
869fn is_generated_artifact(path: &Path) -> bool {
870    const DIRECTORIES: &[&str] = &[
871        "target",
872        "dist",
873        "node_modules",
874        "__pycache__",
875        ".pytest_cache",
876        ".mypy_cache",
877        ".ruff_cache",
878        ".tox",
879        ".nox",
880        ".venv",
881        "venv",
882        ".gradle",
883        ".build",
884        "DerivedData",
885        ".next",
886        ".nuxt",
887        ".svelte-kit",
888        ".turbo",
889        "coverage",
890    ];
891    path.components().any(|component| {
892        let std::path::Component::Normal(name) = component else {
893            return false;
894        };
895        DIRECTORIES
896            .iter()
897            .any(|directory| name == OsStr::new(directory))
898    })
899}
900
901/// Files the desktop writes into any folder a person opens.
902///
903/// Finder drops `.DS_Store` into every directory it shows, so a finished
904/// worktree someone once looked at in Finder held nothing but these, and
905/// `spar clean` kept every one of them as if it were work.
906fn is_desktop_metadata(path: &Path) -> bool {
907    path.file_name() == Some(OsStr::new(".DS_Store"))
908}
909
910fn merge_pr_args<'a>(
911    number: &'a str,
912    expected_head: Option<&'a str>,
913    delete_branch: bool,
914) -> Vec<&'a str> {
915    let mut args = vec!["pr", "merge", number, "--squash"];
916    if delete_branch {
917        args.push("--delete-branch");
918    }
919    if let Some(expected_head) = expected_head {
920        args.extend(["--match-head-commit", expected_head]);
921    }
922    args
923}
924
925/// The line of a failed push where the host says this account may not write
926/// to the repository, as opposed to the branch having moved or the network
927/// failing.
928///
929/// GitHub words it the same over SSH (`ERROR: Permission to o/r.git denied to
930/// u.`) and HTTPS (`remote: Permission to ...`), and in both it comes before
931/// several lines of generic advice, so the last line is the least useful one:
932/// it used to be all spar said, and all it said was "and the repository
933/// exists."
934fn push_denial(message: &str) -> Option<&str> {
935    message
936        .lines()
937        .map(|line| {
938            let line = line.trim();
939            let line = line.strip_prefix("ERROR:").unwrap_or(line);
940            line.strip_prefix("remote:").unwrap_or(line).trim()
941        })
942        .find(|line| line.starts_with("Permission to ") && line.contains(" denied to "))
943}
944
945/// The line of a failed push that names the ref the remote turned down and
946/// why, such as `[rejected] HEAD -> issue-7 (stale info)`.
947fn push_rejection(message: &str) -> Option<&str> {
948    message
949        .lines()
950        .map(|line| line.trim().trim_start_matches('!').trim())
951        .find(|line| line.starts_with("[rejected]") || line.starts_with("[remote rejected]"))
952}
953
954fn push_error(
955    target: &PushTarget,
956    branch: &str,
957    e: &crate::error::SparError,
958) -> crate::error::SparError {
959    let message = e.to_string();
960    if let Some(denied) = push_denial(&message) {
961        return spar_err!("could not push to {}. {denied}", target.describe(branch));
962    }
963    spar_err!(
964        "could not push to {}. {}\nCheck push access and whether the branch moved under you.",
965        target.describe(branch),
966        push_rejection(&message).unwrap_or(e.last_line())
967    )
968}
969
970/// The fork's URL, reached the way origin is: origin's push URL with only its
971/// `owner/repo` path replaced.
972///
973/// Keeping everything but the path is what keeps an SSH host alias, which is
974/// how one machine pushes as two GitHub accounts, a port, such as SSH over
975/// 443 where 22 is blocked, and an scp-style address with no user in it.
976/// GitHub's own URLs for the fork would drop all three. None for anything
977/// that is not a URL or an scp-style address with an `owner/repo` path.
978fn fork_url(origin_url: &str, full_name: &str) -> Option<String> {
979    let origin = origin_url.trim();
980    let path_start = match origin.find("://") {
981        Some(scheme) => {
982            let host = scheme + 3;
983            host + origin[host..].find('/')? + 1
984        }
985        None => {
986            // scp-style is `[user@]host:path`, and git reads a colon after a
987            // slash as part of a local path instead.
988            let colon = origin.find(':')?;
989            if origin[..colon].contains('/') {
990                return None;
991            }
992            colon + 1
993        }
994    };
995    let path = origin[path_start..].trim_end_matches('/');
996    let (path, suffix) = match path.strip_suffix(".git") {
997        Some(bare) => (bare, ".git"),
998        None => (path, ""),
999    };
1000    let segments: Vec<&str> = path.split('/').collect();
1001    if segments.len() < 2 || segments[segments.len() - 2..].iter().any(|s| s.is_empty()) {
1002        return None;
1003    }
1004    let prefix = &segments[..segments.len() - 2];
1005    let prefix = if prefix.is_empty() {
1006        String::new()
1007    } else {
1008        format!("{}/", prefix.join("/"))
1009    };
1010    Some(format!(
1011        "{}{prefix}{full_name}{suffix}",
1012        &origin[..path_start]
1013    ))
1014}
1015
1016/// A fork from what GitHub said about it, reached the way origin is.
1017///
1018/// GitHub's own URL is the fallback for an origin `fork_url` cannot read, the
1019/// SSH one when origin looks like SSH.
1020fn fork_reached_like_origin(
1021    owner: String,
1022    full_name: String,
1023    origin_url: &str,
1024    ssh_url: String,
1025    https_url: String,
1026) -> Fork {
1027    let origin = origin_url.trim();
1028    let url = fork_url(origin, &full_name).unwrap_or_else(|| {
1029        if origin.starts_with("git@") || origin.starts_with("ssh://") {
1030            ssh_url
1031        } else {
1032            https_url
1033        }
1034    });
1035    Fork {
1036        owner,
1037        full_name,
1038        url,
1039    }
1040}
1041
1042#[derive(Deserialize)]
1043struct ForkOwner {
1044    login: String,
1045}
1046
1047/// The fork GitHub made, or already had, in answer to a request to fork.
1048fn fork_from_api(answer: &str, origin_url: &str) -> Result<Fork> {
1049    #[derive(Deserialize)]
1050    struct Answer {
1051        full_name: String,
1052        owner: ForkOwner,
1053        ssh_url: String,
1054        clone_url: String,
1055    }
1056
1057    let answer: Answer = serde_json::from_str(answer.trim())
1058        .map_err(|e| spar_err!("unexpected answer from GitHub when forking: {e}"))?;
1059    Ok(fork_reached_like_origin(
1060        answer.owner.login,
1061        answer.full_name,
1062        origin_url,
1063        answer.ssh_url,
1064        answer.clone_url,
1065    ))
1066}
1067
1068/// The authenticated user's fork from a listing of this repository's forks
1069/// that only the user owns, or None when there is no such fork.
1070fn fork_from_listing(answer: &str, origin_url: &str) -> Result<Option<Fork>> {
1071    #[derive(Deserialize)]
1072    #[serde(rename_all = "camelCase")]
1073    struct Node {
1074        name_with_owner: String,
1075        owner: ForkOwner,
1076        ssh_url: String,
1077        url: String,
1078    }
1079
1080    let nodes: Vec<Node> = serde_json::from_str(answer.trim())
1081        .map_err(|e| spar_err!("unexpected answer from GitHub listing forks: {e}"))?;
1082    Ok(nodes.into_iter().next().map(|node| {
1083        fork_reached_like_origin(
1084            node.owner.login,
1085            node.name_with_owner,
1086            origin_url,
1087            node.ssh_url,
1088            node.url,
1089        )
1090    }))
1091}
1092
1093/// Where spar can push a pull request from a fork: the fork, when it is the
1094/// authenticated user's own, and nowhere when it is anybody else's.
1095fn own_fork_target(pr: &PrView, viewer: &str, origin_url: &str) -> Option<PushTarget> {
1096    let owner = pr.head_repository_owner.as_ref()?.login.trim();
1097    let name = pr.head_repository.as_ref()?.name.trim();
1098    if owner.is_empty() || name.is_empty() || !owner.eq_ignore_ascii_case(viewer.trim()) {
1099        return None;
1100    }
1101    let full_name = format!("{owner}/{name}");
1102    Some(PushTarget::Fork(Fork {
1103        owner: owner.to_string(),
1104        url: fork_url(origin_url, &full_name)?,
1105        full_name,
1106    }))
1107}
1108
1109fn reconcile_pr_creation(
1110    branch: &str,
1111    created: Result<String>,
1112    found: Result<Option<PrRef>>,
1113) -> Result<PrRef> {
1114    match (created, found) {
1115        (_, Ok(Some(pr))) => Ok(pr),
1116        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1117            "PR creation reported success but none was found for {branch}"
1118        ))),
1119        (Err(create), Ok(None)) => Err(spar_err!(
1120            "could not open a PR for {branch}. {}",
1121            create.last_line()
1122        )),
1123        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1124            "PR creation reported success for {branch}, but it could not be verified. {}",
1125            check.last_line()
1126        ))),
1127        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1128            "could not open a PR for {branch}. {} The result could not be verified: {}",
1129            create.last_line(),
1130            check.last_line()
1131        ))),
1132    }
1133}
1134
1135fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
1136    #[derive(Deserialize)]
1137    #[serde(rename_all = "camelCase")]
1138    struct Row {
1139        number: i64,
1140        #[serde(default)]
1141        url: String,
1142        #[serde(default)]
1143        title: String,
1144        base_ref_name: String,
1145    }
1146
1147    let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
1148        spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
1149    })?;
1150    Ok(rows
1151        .into_iter()
1152        .find(|row| row.base_ref_name == base)
1153        .map(|row| PrRef {
1154            number: row.number,
1155            url: row.url,
1156            title: row.title,
1157        }))
1158}
1159
1160/// Sign a comment spar composed, so it can be told from one the same account
1161/// wrote by hand. Idempotent: a body already signed is returned as it is.
1162fn signed(body: &str) -> String {
1163    if body.contains(COMMENT_MARKER) {
1164        return body.to_string();
1165    }
1166    format!("{body}\n\n{COMMENT_MARKER}")
1167}
1168
1169/// Whether a comment body is one spar composed rather than one a person wrote.
1170pub fn is_spar_comment(body: &str) -> bool {
1171    body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
1172}
1173
1174/// What `gh api -i` printed, taken apart.
1175#[derive(Debug, Clone, Default, PartialEq, Eq)]
1176pub struct ApiResponse {
1177    pub status: u16,
1178    /// Header names lowercased, values trimmed.
1179    pub headers: BTreeMap<String, String>,
1180    pub body: String,
1181}
1182
1183/// Split `gh api -i` output into the status, the headers, and the body. None
1184/// when it does not start with a status line, which is what a spawn that
1185/// printed an error looks like.
1186pub fn parse_api_response(stdout: &str) -> Option<ApiResponse> {
1187    let mut lines = stdout.split_inclusive('\n');
1188    let first = lines.next()?.trim_end_matches(['\r', '\n']);
1189    if !first.starts_with("HTTP/") {
1190        return None;
1191    }
1192    let status: u16 = first.split_whitespace().nth(1)?.parse().ok()?;
1193    let mut headers = BTreeMap::new();
1194    let mut body = String::new();
1195    let mut in_body = false;
1196    for line in lines {
1197        if in_body {
1198            body.push_str(line);
1199            continue;
1200        }
1201        let bare = line.trim_end_matches(['\r', '\n']);
1202        if bare.is_empty() {
1203            in_body = true;
1204            continue;
1205        }
1206        if let Some((name, value)) = bare.split_once(':') {
1207            headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
1208        }
1209    }
1210    Some(ApiResponse {
1211        status,
1212        headers,
1213        body,
1214    })
1215}
1216
1217/// The review requests on a pull request's timeline. The last hundred are
1218/// plenty: only the newest one that asks the viewer matters.
1219const REQUESTS_QUERY: &str = "\
1220query($owner: String!, $repo: String!, $number: Int!) {
1221  repository(owner: $owner, name: $repo) {
1222    pullRequest(number: $number) {
1223      timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], last: 100) {
1224        nodes {
1225          ... on ReviewRequestedEvent {
1226            createdAt
1227            requestedReviewer {
1228              ... on User { login }
1229              ... on Team { combinedSlug }
1230            }
1231          }
1232        }
1233      }
1234    }
1235  }
1236}";
1237
1238/// The requests `REQUESTS_QUERY` returned, oldest first. None when the answer
1239/// is not that shape. A request for somebody GitHub no longer knows names
1240/// nobody and is left out, or it would read as a team nobody could check.
1241pub fn parse_review_requests(text: &str) -> Option<Vec<crate::model::ReviewRequested>> {
1242    let v: Value = serde_json::from_str(text).ok()?;
1243    let nodes = v
1244        .pointer("/data/repository/pullRequest/timelineItems/nodes")?
1245        .as_array()?;
1246    let text_at = |node: &Value, path: &str| {
1247        node.pointer(path)
1248            .and_then(Value::as_str)
1249            .unwrap_or_default()
1250            .to_string()
1251    };
1252    Some(
1253        nodes
1254            .iter()
1255            .map(|node| crate::model::ReviewRequested {
1256                at: text_at(node, "/createdAt"),
1257                who: crate::model::Login {
1258                    login: text_at(node, "/requestedReviewer/login"),
1259                    slug: text_at(node, "/requestedReviewer/combinedSlug"),
1260                },
1261            })
1262            .filter(|r| !r.at.is_empty() && !(r.who.login.is_empty() && r.who.slug.is_empty()))
1263            .collect(),
1264    )
1265}
1266
1267/// The pull request a per item state file is about: `pr-12.json`,
1268/// `review-12.json`, `checkin-12.json`. None for anything else, so the shared
1269/// files beside them, `spend.json` and the rest, are never touched.
1270pub(crate) fn state_file_number(name: &str) -> Option<i64> {
1271    let stem = name.strip_suffix(".json")?;
1272    ["pr-", "review-", "checkin-"]
1273        .iter()
1274        .find_map(|prefix| stem.strip_prefix(prefix))
1275        .filter(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
1276        .and_then(|digits| digits.parse().ok())
1277}
1278
1279/// Whether the viewer has already submitted this review: exactly one of these
1280/// bodies, on this commit, and still standing. A clean review reads the same on
1281/// every commit, so a body alone would take an approval of an older commit for
1282/// this one; and a dismissed review was withdrawn, so it is not this one either.
1283///
1284/// More than one body because a review with line comments goes up with a
1285/// shorter one than the same review without them.
1286fn has_viewer_review(reviews: &[Value], viewer: &str, bodies: &[&str], commit: &str) -> bool {
1287    reviews.iter().any(|review| {
1288        let field = |name: &str| review.get(name).and_then(Value::as_str).unwrap_or("");
1289        let standing = !matches!(
1290            field("state").to_uppercase().as_str(),
1291            "DISMISSED" | "PENDING"
1292        );
1293        review
1294            .get("user")
1295            .and_then(|u| u.get("login"))
1296            .and_then(Value::as_str)
1297            .is_some_and(|login| crate::comments::same_login(login, viewer))
1298            && bodies.contains(&field("body"))
1299            && field("commit_id").eq_ignore_ascii_case(commit)
1300            && standing
1301    })
1302}
1303
1304fn reconcile_review_post(
1305    number: i64,
1306    bodies: &[&str],
1307    viewer: &str,
1308    commit: &str,
1309    post_error: crate::error::SparError,
1310    reviews: Result<Vec<Value>>,
1311) -> Result<()> {
1312    match reviews {
1313        Ok(reviews) if has_viewer_review(&reviews, viewer, bodies, commit) => Ok(()),
1314        Ok(_) => Err(post_error),
1315        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1316            "could not submit a review on #{number}. {} The result could not be verified: {}",
1317            post_error.last_line(),
1318            read_error.last_line()
1319        ))),
1320    }
1321}
1322
1323/// The body of a review that carries line comments, for `gh api --input -`.
1324///
1325/// Every comment is on the new side of the diff, which is the only side a
1326/// finding about the branch is ever about.
1327fn review_payload(commit: &str, event: &str, body: &str, comments: &[InlineComment]) -> Value {
1328    let comments: Vec<Value> = comments
1329        .iter()
1330        .map(|c| {
1331            let mut row = serde_json::json!({
1332                "path": c.path,
1333                "line": c.line,
1334                "side": "RIGHT",
1335                "body": c.body,
1336            });
1337            if let Some(start) = c.start_line {
1338                row["start_line"] = serde_json::json!(start);
1339                row["start_side"] = serde_json::json!("RIGHT");
1340            }
1341            row
1342        })
1343        .collect();
1344    serde_json::json!({
1345        "commit_id": commit,
1346        "event": event,
1347        "body": body,
1348        "comments": comments,
1349    })
1350}
1351
1352/// Whether GitHub looked at a write and refused it, rather than failing to
1353/// answer. A refusal is final, so the same write changed is safe to send; a
1354/// timeout or a gateway error says nothing about whether the first one landed.
1355fn refused(e: &crate::error::SparError) -> bool {
1356    e.to_string().contains("(HTTP 422)")
1357}
1358
1359/// The lines of each file a pull request's diff shows on its new side, as
1360/// first and last line of each hunk. GitHub takes a line comment only on one
1361/// of these, and refuses the whole review over one that is not.
1362pub type Hunks = BTreeMap<String, Vec<(u32, u32)>>;
1363
1364/// Read the hunks out of a unified diff.
1365///
1366/// A hunk's lines are counted off against its header rather than recognised by
1367/// their first character, so an added line that itself begins `+++ ` or `@@ `
1368/// is content and not the start of something. A path git had to quote is left
1369/// out: what is between the quotes is escaped, not a path GitHub would match.
1370fn new_side_hunks(diff: &str) -> Hunks {
1371    let mut out = Hunks::new();
1372    let mut path: Option<String> = None;
1373    let (mut old_left, mut new_left) = (0u32, 0u32);
1374    for line in diff.lines() {
1375        if old_left > 0 || new_left > 0 {
1376            match line.as_bytes().first() {
1377                Some(b'+') => new_left = new_left.saturating_sub(1),
1378                Some(b'-') => old_left = old_left.saturating_sub(1),
1379                Some(b'\\') => {}
1380                _ => {
1381                    old_left = old_left.saturating_sub(1);
1382                    new_left = new_left.saturating_sub(1);
1383                }
1384            }
1385            continue;
1386        }
1387        if line.starts_with("diff --git ") {
1388            path = None;
1389        } else if let Some(rest) = line.strip_prefix("+++ ") {
1390            // git ends the line with a tab when the path has a space in it,
1391            // so that a patch tool can tell where the name stops.
1392            let rest = if rest.contains(' ') {
1393                rest.strip_suffix('\t').unwrap_or(rest)
1394            } else {
1395                rest
1396            };
1397            path = rest
1398                .strip_prefix("b/")
1399                .filter(|p| !p.is_empty())
1400                .map(str::to_string);
1401        } else if let Some(((_, old_len), (start, len))) = hunk_header(line) {
1402            old_left = old_len;
1403            new_left = len;
1404            if let (Some(path), true) = (&path, len > 0 && start > 0) {
1405                out.entry(path.clone())
1406                    .or_default()
1407                    .push((start, start + len - 1));
1408            }
1409        }
1410    }
1411    out
1412}
1413
1414/// `@@ -a,b +c,d @@`, as `((a, b), (c, d))`. A count left out is one.
1415fn hunk_header(line: &str) -> Option<((u32, u32), (u32, u32))> {
1416    let ranges = line.strip_prefix("@@ -")?.split_once(" @@")?.0;
1417    let (old, new) = ranges.split_once(" +")?;
1418    let range = |s: &str| -> Option<(u32, u32)> {
1419        match s.split_once(',') {
1420            Some((start, len)) => Some((start.parse().ok()?, len.parse().ok()?)),
1421            None => Some((s.parse().ok()?, 1)),
1422        }
1423    };
1424    Some((range(old)?, range(new)?))
1425}
1426
1427fn has_exact_comment(comments: &[Value], body: &str) -> bool {
1428    comments.iter().any(|comment| {
1429        comment
1430            .get("body")
1431            .and_then(Value::as_str)
1432            .is_some_and(|seen| seen == body)
1433    })
1434}
1435
1436fn reconcile_comment_post(
1437    number: i64,
1438    body: &str,
1439    post_error: crate::error::SparError,
1440    comments: Result<Vec<Value>>,
1441) -> Result<()> {
1442    match comments {
1443        Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
1444        Ok(_) => Err(post_error),
1445        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1446            "could not comment on #{number}. {} The result could not be verified: {}",
1447            post_error.last_line(),
1448            read_error.last_line()
1449        ))),
1450    }
1451}
1452
1453fn reconcile_issue_edit(
1454    number: i64,
1455    wanted: &str,
1456    edit_error: crate::error::SparError,
1457    observed: Result<String>,
1458) -> Result<()> {
1459    match observed {
1460        Ok(body) if body == wanted => Ok(()),
1461        Ok(_) => Err(spar_err!(
1462            "could not rewrite the body of #{number}. {}",
1463            edit_error.last_line()
1464        )),
1465        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1466            "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1467            edit_error.last_line(),
1468            read_error.last_line()
1469        ))),
1470    }
1471}
1472
1473fn issue_url_has_number(url: &str) -> bool {
1474    url.trim()
1475        .rsplit('/')
1476        .next()
1477        .and_then(|tail| tail.parse::<i64>().ok())
1478        .is_some_and(|number| number > 0)
1479}
1480
1481fn reconcile_issue_creation(
1482    title: &str,
1483    created: Result<String>,
1484    found: Result<Option<ExistingIssue>>,
1485) -> Result<String> {
1486    match (created, found) {
1487        (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1488        (_, Ok(Some(issue))) => Ok(issue.url),
1489        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1490            "issue creation reported success but no matching issue was found for {title:?}"
1491        ))),
1492        (Err(create), Ok(None)) => Err(spar_err!(
1493            "could not file issue {title:?}. {}",
1494            create.last_line()
1495        )),
1496        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1497            "issue creation reported success for {title:?}, but it could not be verified. {}",
1498            check.last_line()
1499        ))),
1500        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1501            "could not file issue {title:?}. {} The result could not be verified: {}",
1502            create.last_line(),
1503            check.last_line()
1504        ))),
1505    }
1506}
1507
1508fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1509    if output.trim().is_empty() {
1510        return Ok(None);
1511    }
1512    for line in output.lines() {
1513        let mut fields = line.split_whitespace();
1514        let oid = fields.next().unwrap_or_default();
1515        let name = fields.next().unwrap_or_default();
1516        if name == remote_ref && !oid.is_empty() {
1517            return Ok(Some(oid.to_string()));
1518        }
1519    }
1520    Err(spar_err!(
1521        "origin returned an unexpected ref listing for {remote_ref}"
1522    ))
1523}
1524
1525fn reconcile_failed_split_push(
1526    branch: &str,
1527    push_error: crate::error::SparError,
1528    local: Result<String>,
1529    remote: Result<String>,
1530) -> std::result::Result<(), SplitPushError> {
1531    let remote_ref = format!("refs/heads/{branch}");
1532    match (local, remote) {
1533        (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1534            Ok(Some(oid)) if oid == local.trim() => Ok(()),
1535            Ok(_) => Err(SplitPushError::new(
1536                format!(
1537                    "could not create origin/{branch}. {} The remote branch is absent or points \
1538                     somewhere else. Nothing was overwritten.",
1539                    push_error.last_line()
1540                ),
1541                false,
1542            )),
1543            Err(check) => Err(SplitPushError::new(
1544                format!(
1545                    "could not confirm whether origin/{branch} was created. {} The remote result \
1546                     could not be verified: {}",
1547                    push_error.last_line(),
1548                    check.last_line()
1549                ),
1550                true,
1551            )),
1552        },
1553        (local, remote) => {
1554            let check = match (local, remote) {
1555                (Err(local), Err(remote)) => format!(
1556                    "the local commit could not be read: {}; origin could not be read: {}",
1557                    local.last_line(),
1558                    remote.last_line()
1559                ),
1560                (Err(local), _) => {
1561                    format!("the local commit could not be read: {}", local.last_line())
1562                }
1563                (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1564                _ => unreachable!(),
1565            };
1566            Err(SplitPushError::new(
1567                format!(
1568                    "could not confirm whether origin/{branch} was created. {} The result could \
1569                     not be verified because {check}",
1570                    push_error.last_line()
1571                ),
1572                true,
1573            ))
1574        }
1575    }
1576}
1577
1578impl Repo {
1579    pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1580        let root =
1581            std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1582        // A linked worktree has a `.git` file rather than a directory, and a
1583        // bare-ish layout can have neither, so ask git instead of guessing.
1584        let inside = proc::run_str(
1585            &["git", "rev-parse", "--is-inside-work-tree"],
1586            &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1587        )
1588        .unwrap_or_default();
1589        if inside.trim() != "true" {
1590            bail!("not a git repository: {}", root.display());
1591        }
1592        let repo = Self {
1593            root,
1594            style: cfg.style.clone(),
1595            branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1596            state_store: cfg.loop_cfg.state_store,
1597            followups: cfg.loop_cfg.followups,
1598            drafts: cfg.loop_cfg.drafts,
1599            fork_when_denied: cfg.loop_cfg.fork_when_denied,
1600            setup: cfg.loop_cfg.setup.clone(),
1601            isolate_reads: cfg.loop_cfg.worktrees,
1602            keep_worktrees: cfg.loop_cfg.keep_worktrees,
1603            fork: OnceLock::new(),
1604            viewer: OnceLock::new(),
1605            checkpoints: Mutex::new(BTreeMap::new()),
1606            writes: WriteStats::default(),
1607        };
1608        repo.self_exclude();
1609        Ok(repo)
1610    }
1611
1612    /// Keep spar's own scratch directories out of the target repo's
1613    /// `git status`.
1614    ///
1615    /// Written to `.git/info/exclude`, never to a tracked `.gitignore`: this is
1616    /// somebody else's repository and spar has no business committing to it.
1617    /// Best effort and silent on failure, because a read-only git directory is
1618    /// not a reason to abandon a run.
1619    fn self_exclude(&self) {
1620        let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1621        let git_dir = git_dir.trim();
1622        if git_dir.is_empty() {
1623            return;
1624        }
1625        let path = Path::new(git_dir).join("info").join("exclude");
1626        let existing = std::fs::read_to_string(&path).unwrap_or_default();
1627
1628        // The marker too. It is written into whichever directory the call ran
1629        // in, which for a shared checkout is the repository root, and an
1630        // untracked file there is one `git add -A` away from being committed.
1631        // The error names its path, which is how a person is meant to find it.
1632        let wanted = [
1633            format!("/{WORKTREE_DIR}/"),
1634            format!("/{STATE_DIR}/"),
1635            format!("{RECOVERY_MARKER_PREFIX}*"),
1636        ];
1637        let missing: Vec<&String> = wanted
1638            .iter()
1639            .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1640            .collect();
1641        if missing.is_empty() {
1642            return;
1643        }
1644
1645        use std::io::Write;
1646        if let Some(parent) = path.parent() {
1647            let _ = std::fs::create_dir_all(parent);
1648        }
1649        let mut block = String::new();
1650        if !existing.is_empty() && !existing.ends_with('\n') {
1651            block.push('\n');
1652        }
1653        block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1654        for line in missing {
1655            block.push_str(line);
1656            block.push('\n');
1657        }
1658        if let Ok(mut file) = std::fs::OpenOptions::new()
1659            .create(true)
1660            .append(true)
1661            .open(&path)
1662        {
1663            let _ = file.write_all(block.as_bytes());
1664        }
1665    }
1666
1667    pub fn root(&self) -> &Path {
1668        &self.root
1669    }
1670
1671    pub(crate) fn write_summary(&self) -> WriteSummary {
1672        WriteSummary {
1673            attempted: self.writes.attempted.load(Ordering::Relaxed),
1674            failed: self.writes.failed.load(Ordering::Relaxed),
1675        }
1676    }
1677
1678    pub(crate) fn record_write<T, E>(
1679        &self,
1680        result: std::result::Result<T, E>,
1681    ) -> std::result::Result<T, E> {
1682        self.record_write_outcome(result.is_err());
1683        result
1684    }
1685
1686    pub(crate) fn record_failed_write<T, E>(
1687        &self,
1688        result: std::result::Result<T, E>,
1689    ) -> std::result::Result<T, E> {
1690        if result.is_err() {
1691            self.record_write_outcome(true);
1692        }
1693        result
1694    }
1695
1696    fn record_write_outcome(&self, failed: bool) {
1697        self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1698        if failed {
1699            self.writes.failed.fetch_add(1, Ordering::Relaxed);
1700        }
1701    }
1702
1703    // -- gates ------------------------------------------------------------
1704
1705    /// Scrub, then verify. A leak here reaches GitHub, so it is a hard error
1706    /// rather than a warning: silent partial compliance is how a style rule
1707    /// erodes over a long run.
1708    pub fn clean(&self, text: &str) -> Result<String> {
1709        let out = style::scrub(text, &self.style);
1710        let bad = style::violations(&out, &self.style);
1711        if !bad.is_empty() {
1712            bail!(
1713                "style gate could not clean text ({}): {}",
1714                bad.join(", "),
1715                style::clip(&out, 300)
1716            );
1717        }
1718        Ok(out)
1719    }
1720
1721    /// Clean, and hold to a length budget. For anything a model wrote.
1722    pub fn clean_body(&self, text: &str) -> Result<String> {
1723        self.clean(&style::body(text, &self.style))
1724    }
1725
1726    /// The same, with an issue's far larger budget and its exemption for code.
1727    pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1728        self.clean(&style::issue_body(text, &self.style))
1729    }
1730
1731    /// The single transform every outbound title goes through.
1732    ///
1733    /// Scrub first, clip second, and never the other way round. Clipping first
1734    /// lets the scrub lengthen the result past the budget (an em dash becomes
1735    /// two characters), so a second pass would clip again and produce a
1736    /// different string. That broke follow-up deduplication silently: the
1737    /// lookup searched for one title while GitHub had stored another, no match
1738    /// was ever found, and a fresh duplicate issue was filed every review
1739    /// round. Doing it in this order makes the transform idempotent, which the
1740    /// tests assert.
1741    pub fn clean_title(&self, text: &str) -> Result<String> {
1742        Ok(style::title(&self.clean(text)?, &self.style))
1743    }
1744
1745    pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1746        let title = self.record_failed_write(self.clean_title(text))?;
1747        if title.trim().is_empty() {
1748            return self.record_failed_write(Err(spar_err!(
1749                "nothing left of the title after cleaning it"
1750            )));
1751        }
1752        Ok(title)
1753    }
1754
1755    pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1756        if self.followups == Followups::Issues {
1757            self.clean_nonempty_title_for_write(text)
1758        } else {
1759            self.clean_title(text)
1760        }
1761    }
1762
1763    // -- git --------------------------------------------------------------
1764
1765    fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1766        ExecOpts::new()
1767            .cwd(cwd.unwrap_or(&self.root))
1768            .check(check)
1769            .timeout_secs(600)
1770    }
1771
1772    pub fn git(&self, args: &[&str]) -> Result<String> {
1773        self.git_at(None, args)
1774    }
1775
1776    pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1777        let argv = git_without_maintenance_argv(args);
1778        proc::run(&argv, &self.git_opts(cwd, true))
1779    }
1780
1781    /// Run a parent-side Git operation without inherited background helpers.
1782    ///
1783    /// Editing calls are untrusted. Once one returns, status, staging, and
1784    /// committing happen in this process, so an inherited fsmonitor or automatic
1785    /// maintenance command must not become a way to execute outside its sandbox.
1786    fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1787        let argv = git_without_automation_argv(args);
1788        proc::run(
1789            &argv,
1790            &self.git_opts(Some(cwd), true).stop_descendants(true),
1791        )
1792    }
1793
1794    fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1795        let argv = git_without_automation_argv(args);
1796        proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1797            .map(|output| output.ok())
1798    }
1799
1800    /// Run git, tolerating failure. Returns whatever landed on stdout.
1801    pub fn git_try(&self, args: &[&str]) -> String {
1802        self.git_try_at(None, args)
1803    }
1804
1805    pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1806        let argv = git_without_maintenance_argv(args);
1807        proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1808    }
1809
1810    /// The base branch the remote actually points at, rather than assuming
1811    /// `main`. Falls back to the configured value when there is no origin.
1812    pub fn default_branch(&self, configured: &str) -> String {
1813        let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1814        match refname.trim().rsplit('/').next() {
1815            Some(name) if !name.is_empty() => name.to_string(),
1816            _ => configured.to_string(),
1817        }
1818    }
1819
1820    // -- branch naming and ownership --------------------------------------
1821    //
1822    // Branch names default to `issue-N`, which is exactly what a person would
1823    // name a branch by hand. Ownership therefore cannot be inferred from the
1824    // name, so every branch spar creates is recorded and cleanup only ever
1825    // touches what is in that record.
1826
1827    pub fn branch_for_issue(&self, issue: i64) -> String {
1828        format!("{}issue-{issue}", self.branch_prefix)
1829    }
1830
1831    pub fn branch_for_pr(&self, number: i64) -> String {
1832        format!("{}pr-{number}", self.branch_prefix)
1833    }
1834
1835    /// One part of a split, numbered from 1 within its parent.
1836    ///
1837    /// Its own namespace rather than `issue-N`, because the parts of a split
1838    /// pull request have no issue of their own and would otherwise collide with
1839    /// the branch of the issue that happens to share the parent's number.
1840    ///
1841    /// The name a part is tried on first. `worktree_for_split` may end up on a
1842    /// suffixed one, because this name is not free forever.
1843    pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1844        format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1845    }
1846
1847    fn ledger_path(&self) -> PathBuf {
1848        self.root.join(STATE_DIR).join("branches.json")
1849    }
1850
1851    pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1852        std::fs::read_to_string(self.ledger_path())
1853            .ok()
1854            .and_then(|text| serde_json::from_str(&text).ok())
1855            .unwrap_or_default()
1856    }
1857
1858    pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1859        let mut data = self.known_branches();
1860        data.insert(
1861            branch.to_string(),
1862            BranchRecord {
1863                kind: kind.to_string(),
1864                number,
1865            },
1866        );
1867        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1868            logdim!("could not record branch {branch}: {e}");
1869        }
1870    }
1871
1872    pub fn forget_branch(&self, branch: &str) {
1873        let mut data = self.known_branches();
1874        if data.remove(branch).is_none() {
1875            return;
1876        }
1877        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1878            logdim!("could not update the branch record: {e}");
1879        }
1880    }
1881
1882    // -- worktrees --------------------------------------------------------
1883
1884    fn worktree_path(&self, name: &str) -> PathBuf {
1885        self.root.join(WORKTREE_DIR).join(name)
1886    }
1887
1888    /// Run `[loop] setup` in a worktree spar has just created.
1889    ///
1890    /// Before anything else looks at the tree, so the baseline an editing call
1891    /// is measured against already holds what setup wrote, and a setup that
1892    /// fails costs no agent call. The worktree is kept as it is: what an
1893    /// install writes is build output, which the next run rebuilds over.
1894    fn set_up_worktree(&self, path: &Path, label: &str) -> Result<()> {
1895        let argv = self.setup.argv();
1896        if argv.is_empty() {
1897            return Ok(());
1898        }
1899        let command = self.setup.describe();
1900        logdim!("setting up the {label}: {command}");
1901        let out = proc::exec(
1902            &argv,
1903            &ExecOpts::new()
1904                .cwd(path)
1905                .check(false)
1906                .timeout_secs(SETUP_TIMEOUT_SECS)
1907                .stop_descendants(true),
1908        )
1909        .map_err(|e| spar_err!("could not run the setup `{command}` for the {label}: {e}"))?;
1910        if !out.ok() {
1911            let text = format!("{}\n{}", out.stdout.trim_end(), out.stderr.trim_end());
1912            bail!(
1913                "the setup `{command}` failed in the {label} at {}, so no agent was asked to \
1914                 work in it.\n{}",
1915                path.display(),
1916                style::clip(text.trim(), 4000)
1917            );
1918        }
1919        Ok(())
1920    }
1921
1922    /// Isolate an issue so a failed run cannot poison the next one's base.
1923    pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1924        let branch = self.branch_for_issue(issue);
1925        let path = self.worktree_path(&format!("issue-{issue}"));
1926
1927        self.refuse_issue_branch_rebuild(issue, base)?;
1928        self.refuse_dirty_worktree(
1929            &path,
1930            &format!("worktree for issue #{issue}"),
1931            Disposable::BuildOutput,
1932        )?;
1933
1934        if !self.branch_deletion_is_safe(&branch)? {
1935            bail!(
1936                "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1937                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1938                 before retrying."
1939            );
1940        }
1941
1942        if !self.remove_worktree_at(&path)? {
1943            bail!(
1944                "the existing worktree for issue #{issue} could not be removed safely. Its \
1945                 branch was kept."
1946            );
1947        }
1948        if !self.delete_branch_if_safe(&branch)? {
1949            bail!(
1950                "the existing branch {branch} changed or remained checked out while its \
1951                 worktree was being rebuilt. It was kept."
1952            );
1953        }
1954
1955        if let Some(parent) = path.parent() {
1956            std::fs::create_dir_all(parent)
1957                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1958        }
1959
1960        let path_str = path.display().to_string();
1961        let remote_start = format!("origin/{base}");
1962        let created = self
1963            .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1964            .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1965
1966        // Recorded on both paths: an unrecorded branch is one cleanup will
1967        // never remove, and the fallback creates a branch just the same.
1968        created.map_err(|e| {
1969            spar_err!(
1970                "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1971                 and does `origin` exist?",
1972                e.last_line()
1973            )
1974        })?;
1975        self.record_branch(&branch, "issue", issue);
1976        self.set_up_worktree(&path, &format!("worktree for issue #{issue}"))?;
1977        Ok((path, branch))
1978    }
1979
1980    /// Refuse to reset the local or remote branch assigned to an issue when it
1981    /// carries work that no pull request preserves.
1982    ///
1983    /// Both linked-worktree mode and shared-checkout mode rebuild the same
1984    /// branch name. Keeping the guard here prevents either path from silently
1985    /// replacing recovery commits left by an earlier run.
1986    pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1987        let branch = self.branch_for_issue(issue);
1988        let base_remote_ref = format!("refs/heads/{base}");
1989        let base_tracking_ref = format!("refs/remotes/origin/{base}");
1990        let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1991        self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1992            .map_err(|e| {
1993                spar_err!(
1994                    "could not refresh origin/{base} before checking issue #{issue}: {}",
1995                    e.last_line()
1996                )
1997            })?;
1998
1999        if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
2000            let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
2001            if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
2002                bail!(
2003                    "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
2004                     pull request accounts for them. Rebuilding it would force push over that \
2005                     work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
2006                     it, or delete it with `git push origin --delete {branch}` if the remote \
2007                     branch is no longer needed."
2008                );
2009            }
2010        }
2011
2012        // The same question of the fork a refused push would go to, asked here
2013        // rather than after an agent has spent its call implementing.
2014        if self.fork_when_denied && self.origin_refuses_push() {
2015            match self.existing_fork() {
2016                Ok(Some(fork)) => {
2017                    self.fork_branch_lease(&fork, &branch, base)?;
2018                }
2019                Ok(None) => {}
2020                Err(e) => logdim!("{e} It is checked again before anything is pushed to it."),
2021            }
2022        }
2023
2024        let local_ref = format!("refs/heads/{branch}");
2025        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2026            let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
2027            let recorded_pr = self
2028                .known_branches()
2029                .get(&branch)
2030                .is_some_and(|record| record.kind == "pr");
2031            let preserved = ahead == 0
2032                || if recorded_pr {
2033                    self.local_branch_is_preserved(&branch)?
2034                } else {
2035                    self.pull_request_holds(&branch, &local_ref, base)
2036                };
2037            if !preserved {
2038                let listed = self
2039                    .commit_lines(&self.root, &local_ref, base)
2040                    .iter()
2041                    .map(|line| format!("  {line}"))
2042                    .collect::<Vec<_>>()
2043                    .join("\n");
2044                bail!(
2045                    "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
2046                     no pull request preserves them. Rebuilding it would delete the only copy.\n\
2047                     {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
2048                     it, or delete it with `git branch -D {branch}` if it is stale."
2049                );
2050            }
2051        }
2052        Ok(())
2053    }
2054
2055    fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
2056        let live_ref = format!("refs/heads/{branch}");
2057        let tracking_ref = format!("refs/remotes/origin/{branch}");
2058        let listed = self
2059            .git(&["ls-remote", "--heads", "origin", &live_ref])
2060            .map_err(|e| {
2061                spar_err!(
2062                    "could not verify whether origin/{branch} still exists: {}",
2063                    e.last_line()
2064                )
2065            })?;
2066
2067        if remote_head_oid(&listed, &live_ref)?.is_some() {
2068            let refspec = format!("+{live_ref}:{tracking_ref}");
2069            self.git(&["fetch", "--no-tags", "origin", &refspec])
2070                .map_err(|e| {
2071                    spar_err!(
2072                        "origin/{branch} exists but its tracking ref could not be refreshed: {}",
2073                        e.last_line()
2074                    )
2075                })?;
2076            if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2077                bail!("origin/{branch} was fetched but its tracking ref is missing");
2078            }
2079            return Ok(Some(tracking_ref));
2080        }
2081
2082        if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2083            return Ok(None);
2084        }
2085        let expected = self
2086            .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
2087            .trim()
2088            .to_string();
2089        self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
2090            .map_err(|e| {
2091                spar_err!(
2092                    "could not discard stale origin/{branch} tracking ref safely: {}",
2093                    e.last_line()
2094                )
2095            })?;
2096        if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2097            bail!(
2098                "origin/{branch} changed while its stale tracking ref was being removed. It was \
2099                 kept."
2100            );
2101        }
2102        Ok(None)
2103    }
2104
2105    /// Whether a pull request from `branch` already holds every commit `refname`
2106    /// has beyond `base`.
2107    ///
2108    /// GitHub serves `refs/pull/N/head` for as long as the repository lives, so
2109    /// commits that reached a pull request outlive the branch they were pushed
2110    /// from. A matching branch name does not establish that on its own: an
2111    /// issue worked twice reuses the name, and the merged pull request from the
2112    /// first round says nothing about where the second round's commits are.
2113    fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
2114        self.prs_for_branch(branch)
2115            .iter()
2116            .any(|pr| self.pr_head_holds(pr.number, refname, base))
2117    }
2118
2119    fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
2120        let head = format!("refs/spar/pr-head/{number}");
2121        let refspec = format!("+refs/pull/{number}/head:{head}");
2122        if self.git(&["fetch", "origin", &refspec]).is_err() {
2123            return false;
2124        }
2125        let held = self.commits_held_by(refname, base, &head);
2126        self.git_try(&["update-ref", "-d", &head]);
2127        held
2128    }
2129
2130    pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
2131        let argv = vec![
2132            "git".to_string(),
2133            "merge-base".to_string(),
2134            "--is-ancestor".to_string(),
2135            older.to_string(),
2136            newer.to_string(),
2137        ];
2138        let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
2139        match out.code {
2140            0 => Ok(true),
2141            1 => Ok(false),
2142            _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
2143        }
2144    }
2145
2146    fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
2147        let head = format!("refs/spar/pr-head/{number}");
2148        let refspec = format!("+refs/pull/{number}/head:{head}");
2149        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2150            spar_err!(
2151                "could not verify the immutable head of PR #{number}: {}",
2152                e.last_line()
2153            )
2154        })?;
2155        let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
2156        self.git_try(&["update-ref", "-d", &head]);
2157        held
2158    }
2159
2160    fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
2161        let text = self.gh(&[
2162            "pr",
2163            "list",
2164            "--head",
2165            branch,
2166            "--state",
2167            "all",
2168            "--json",
2169            "number,url,title",
2170        ])?;
2171        serde_json::from_str(text.trim())
2172            .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
2173    }
2174
2175    fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
2176        let branch_ref = format!("refs/heads/{branch}");
2177        if record.kind == "pr" {
2178            return self.pr_head_contains_checked(record.number, &branch_ref);
2179        }
2180        let prs = self.branch_prs_checked(branch)?;
2181        if prs.is_empty() {
2182            return Ok(false);
2183        }
2184        for pr in prs {
2185            if self.pr_head_contains_checked(pr.number, &branch_ref)? {
2186                return Ok(true);
2187            }
2188        }
2189        Ok(false)
2190    }
2191
2192    fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
2193        let local_ref = format!("refs/heads/{branch}");
2194        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2195            return Ok(true);
2196        }
2197        let oid = self
2198            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2199            .trim()
2200            .to_string();
2201        let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
2202        if !durable_tip {
2203            let remote_ref = format!("refs/heads/{branch}");
2204            let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
2205            durable_tip = remote.lines().any(|line| {
2206                line.split_whitespace()
2207                    .next()
2208                    .is_some_and(|remote_oid| remote_oid == oid)
2209            });
2210        }
2211        if !durable_tip {
2212            if let Some(record) = self.known_branches().get(branch) {
2213                durable_tip = self.branch_is_preserved_checked(branch, record)?;
2214            }
2215        }
2216        if !durable_tip {
2217            return Ok(false);
2218        }
2219        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2220    }
2221
2222    /// Delete a branch only if its exact current tip and reflog are still safe.
2223    /// The expected old value makes a concurrent ref update fail instead of
2224    /// deleting work that appeared after the preservation check.
2225    fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
2226        let local_ref = format!("refs/heads/{branch}");
2227        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2228            return Ok(true);
2229        }
2230        let expected = self
2231            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2232            .trim()
2233            .to_string();
2234        if !self.branch_deletion_is_safe(branch)? {
2235            return Ok(false);
2236        }
2237        let checked_out = self
2238            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
2239            .lines()
2240            .any(|line| line == format!("branch {local_ref}"));
2241        if checked_out {
2242            return Ok(false);
2243        }
2244        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2245        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2246    }
2247
2248    fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
2249        let local_ref = review_ref(number);
2250        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2251            return Ok(true);
2252        }
2253        let oid = self
2254            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2255            .trim()
2256            .to_string();
2257        if !self.pr_head_contains_checked(number, &local_ref)? {
2258            return Ok(false);
2259        }
2260        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2261    }
2262
2263    fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
2264        let local_ref = review_ref(number);
2265        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2266            return Ok(true);
2267        }
2268        let expected = self
2269            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2270            .trim()
2271            .to_string();
2272        if !self.review_ref_deletion_is_safe(number)? {
2273            return Ok(false);
2274        }
2275        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2276        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2277    }
2278
2279    /// Whether `other` already contains every commit `branch` has beyond
2280    /// `base`. False when either ref fails to resolve, so a ref that is not
2281    /// there cannot vouch for anything.
2282    pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
2283        let range = format!("{}..{branch}", self.base_ref(&self.root, base));
2284        self.git_try(&["rev-list", "--count", &range, "--not", other])
2285            .trim()
2286            == "0"
2287    }
2288
2289    pub fn worktree_remove(&self, issue: i64) -> bool {
2290        let path = self.worktree_path(&format!("issue-{issue}"));
2291        match self.remove_worktree_at(&path) {
2292            Ok(removed) => removed,
2293            Err(error) => {
2294                logdim!(
2295                    "kept {} because removal did not reach a confirmed quiet point: {}",
2296                    path.display(),
2297                    error.last_line()
2298                );
2299                false
2300            }
2301        }
2302    }
2303
2304    /// Verify both the common Git directory and the worktree top level.
2305    ///
2306    /// A stale worktree entry is not ownership proof. An unrelated repository
2307    /// can later occupy the same path and must survive cleanup.
2308    fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
2309        let wanted = std::fs::canonicalize(path)
2310            .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
2311        // Every SPAR worktree path is built from the canonical repository root.
2312        // A different canonical path therefore means the final component or
2313        // one of its parents is a symlink. Passing that alias to `git worktree
2314        // remove` can delete the worktree at its real target.
2315        if wanted != path {
2316            return Ok(false);
2317        }
2318        let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
2319            let raw = PathBuf::from(value.trim());
2320            let joined = if raw.is_absolute() {
2321                raw
2322            } else {
2323                cwd.join(raw)
2324            };
2325            std::fs::canonicalize(&joined)
2326                .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
2327        };
2328        let expected =
2329            self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
2330        let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
2331        let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
2332        let expected = resolve(&self.root, &expected)?;
2333        let actual = resolve(path, &actual)?;
2334        let top = resolve(path, &top)?;
2335        Ok(expected == actual && top == wanted)
2336    }
2337
2338    /// Remove only a worktree that belongs to this repository.
2339    ///
2340    /// The path sits under a predictable directory, but that does not establish
2341    /// ownership. A clean independent repository at the same path must survive
2342    /// even when `git worktree remove` rejects it.
2343    fn remove_worktree_at_with_force(
2344        &self,
2345        path: &Path,
2346        force: bool,
2347        disposable: Disposable,
2348    ) -> Result<bool> {
2349        let existed = path.exists();
2350        if path.exists() {
2351            match self.worktree_belongs_to_repo(path) {
2352                Ok(true) => {}
2353                Ok(false) => {
2354                    logdim!(
2355                        "kept {} because it is not a worktree owned by this repository",
2356                        path.display()
2357                    );
2358                    return Ok(false);
2359                }
2360                Err(e) => {
2361                    logdim!(
2362                        "kept {} because its worktree ownership could not be verified: {}",
2363                        path.display(),
2364                        e.last_line()
2365                    );
2366                    return Ok(false);
2367                }
2368            }
2369            if !force {
2370                match self.has_recoverable_work(path, disposable) {
2371                    Ok(true) => {
2372                        logdim!(
2373                            "kept {} because it contains recoverable files or repository state",
2374                            path.display()
2375                        );
2376                        return Ok(false);
2377                    }
2378                    Err(e) => {
2379                        logdim!(
2380                            "kept {} because its recoverable state could not be checked: {}",
2381                            path.display(),
2382                            e.last_line()
2383                        );
2384                        return Ok(false);
2385                    }
2386                    Ok(false) => {}
2387                }
2388            }
2389        }
2390        // Git refuses to remove a worktree holding any untracked file, which is
2391        // the same question SPAR has just answered in more detail: under
2392        // `ReviewOutput` the checks above have already established that nothing
2393        // there is worth keeping. `--force` here is that answer being carried
2394        // out, not the check being skipped, which is what `force` means.
2395        let path_str = path.display().to_string();
2396        let command_ok = if force || disposable == Disposable::ReviewOutput {
2397            self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
2398        } else {
2399            self.git_try_without_automation(&["worktree", "remove", &path_str])?
2400        };
2401        Ok((command_ok || !existed) && !path.exists())
2402    }
2403
2404    fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
2405        self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
2406    }
2407
2408    /// Remove a review checkout that is about to be rebuilt from origin.
2409    fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
2410        self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
2411    }
2412
2413    /// Force removal is reserved for the explicit `clean --all` path.
2414    fn remove_worktree_at_force(&self, path: &Path) -> bool {
2415        match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
2416            Ok(removed) => removed,
2417            Err(error) => {
2418                logdim!(
2419                    "kept {} because removal did not reach a confirmed quiet point: {}",
2420                    path.display(),
2421                    error.last_line()
2422                );
2423                false
2424            }
2425        }
2426    }
2427
2428    /// Remove a worktree only after a caller has verified it is unchanged.
2429    ///
2430    /// There is deliberately no force fallback, so Git can still refuse a
2431    /// removal if tracked or non-ignored work appears after the final check.
2432    fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
2433        if path.exists() && !self.worktree_belongs_to_repo(path)? {
2434            bail!(
2435                "{} is not a worktree owned by this repository, so it was kept",
2436                path.display()
2437            );
2438        }
2439        if path.exists() && self.has_recoverable_work(path, disposable)? {
2440            bail!(
2441                "the verified worktree at {} contains recoverable files or repository state. It \
2442                 was kept.",
2443                path.display()
2444            );
2445        }
2446        let path_str = path.display().to_string();
2447        self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
2448            .map_err(|e| {
2449                e.with_message(format!(
2450                    "could not remove the verified worktree at {}: {}. It was kept.",
2451                    path.display(),
2452                    e.last_line()
2453                ))
2454            })?;
2455        Ok(!path.exists())
2456    }
2457
2458    fn refuse_dirty_worktree(
2459        &self,
2460        path: &Path,
2461        label: &str,
2462        disposable: Disposable,
2463    ) -> Result<()> {
2464        if !path.is_dir() {
2465            return Ok(());
2466        }
2467        let has_files = std::fs::read_dir(path)
2468            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2469            .next()
2470            .is_some();
2471        let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
2472            spar_err!(
2473                "could not verify whether the existing {label} at {} belongs to this repository, \
2474                 so it was kept: {}",
2475                path.display(),
2476                e.last_line()
2477            )
2478        })?;
2479        if !owned {
2480            if has_files {
2481                bail!(
2482                    "the existing {label} at {} is not a worktree owned by \
2483                     this repository. Refusing to remove it.",
2484                    path.display()
2485                );
2486            }
2487            return Ok(());
2488        }
2489        if !path.join(".git").exists() {
2490            if has_files {
2491                bail!(
2492                    "the existing {label} at {} is not a readable Git worktree and is not empty. \
2493                     Refusing to remove it.",
2494                    path.display()
2495                );
2496            }
2497            return Ok(());
2498        }
2499        let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2500            spar_err!(
2501                "could not verify whether the existing {label} at {} is clean, so it was kept: \
2502                 {}",
2503                path.display(),
2504                e.last_line()
2505            )
2506        })?;
2507        if dirty {
2508            bail!(
2509                "the existing {label} contains uncommitted changes or ignored files at {}. \
2510                 Rebuilding it would delete those files.\nCommit or recover them before running this \
2511                 command again, or use `spar clean --all` if they are not needed.",
2512                path.display()
2513            );
2514        }
2515        Ok(())
2516    }
2517
2518    /// Check an existing PR branch out into an isolated worktree, from
2519    /// wherever `target` says the branch is published.
2520    pub fn worktree_for_pr(&self, pr: &PrView, target: &PushTarget) -> Result<(PathBuf, String)> {
2521        let head = pr.head_ref_name.clone();
2522        if head.trim().is_empty() {
2523            bail!("PR #{} has no head branch to check out", pr.number);
2524        }
2525        let path = self.worktree_path(&format!("pr-{}", pr.number));
2526        let local = self.branch_for_pr(pr.number);
2527
2528        let unfetched = |e: crate::error::SparError| {
2529            spar_err!(
2530                "could not fetch the branch behind PR #{}: {}",
2531                pr.number,
2532                e.last_line()
2533            )
2534        };
2535        // What the worktree starts from, the ref behind it, and what to call
2536        // it when saying so.
2537        let (start, start_ref, shown) = match target {
2538            PushTarget::Origin => {
2539                self.git(&["fetch", "origin", &head]).map_err(unfetched)?;
2540                let start = format!("origin/{head}");
2541                (start.clone(), format!("refs/remotes/origin/{head}"), start)
2542            }
2543            // A fork has no remote here to track it, so its branch is
2544            // fetched into a ref of spar's own.
2545            PushTarget::Fork(fork) => {
2546                let start_ref = format!("refs/spar/fork/{head}");
2547                let refspec = format!("+refs/heads/{head}:{start_ref}");
2548                self.git(&["fetch", "--no-tags", &fork.url, &refspec])
2549                    .map_err(unfetched)?;
2550                (start_ref.clone(), start_ref, target.describe(&head))
2551            }
2552        };
2553        let local_ref = format!("refs/heads/{local}");
2554        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2555            let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2556            if unpushed > 0 {
2557                bail!(
2558                    "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2559                     on {shown}. Rebuilding it would delete their branch.\nInspect the worktree at \
2560                     {} and push or recover those commits before running this command again.",
2561                    pr.number,
2562                    path.display()
2563                );
2564            }
2565        }
2566        self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2567        self.refuse_dirty_worktree(
2568            &path,
2569            &format!("worktree for PR #{}", pr.number),
2570            Disposable::BuildOutput,
2571        )?;
2572        if !self.branch_deletion_is_safe(&local)? {
2573            bail!(
2574                "the existing branch {local} has a tip or reflog-only commit that no surviving \
2575                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2576                 before retrying."
2577            );
2578        }
2579        if !self.remove_worktree_at(&path)? {
2580            bail!(
2581                "the existing worktree for PR #{} could not be removed safely. Its branch was \
2582                 kept.",
2583                pr.number
2584            );
2585        }
2586        if !self.delete_branch_if_safe(&local)? {
2587            bail!(
2588                "the existing branch {local} changed or remained checked out while the PR \
2589                 worktree was being rebuilt. It was kept."
2590            );
2591        }
2592
2593        let path_str = path.display().to_string();
2594        self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2595        self.record_branch(&local, "pr", pr.number);
2596        self.set_up_worktree(&path, &format!("worktree for PR #{}", pr.number))?;
2597        Ok((path, head))
2598    }
2599
2600    /// A retry needs a fresh checkout, not permission to delete ignored data.
2601    /// Keep that data and its branch when all commits are already published.
2602    fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2603        if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2604            return Ok(());
2605        }
2606        let current =
2607            self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2608        if current.trim() != branch
2609            || self.has_uncommitted_changes(path)?
2610            || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2611        {
2612            return Ok(());
2613        }
2614        static NEXT: AtomicUsize = AtomicUsize::new(0);
2615        let (name, retained) = loop {
2616            let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2617            let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2618            let retained = self.worktree_path(&name);
2619            if !retained
2620                .try_exists()
2621                .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2622                && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2623            {
2624                break (name, retained);
2625            }
2626        };
2627        // Neither command overwrites an existing destination. The branch rename
2628        // preserves its reflog, and the worktree move preserves every file.
2629        self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2630        if let Err(error) = self.git_at_without_automation(
2631            &self.root,
2632            &[
2633                "worktree",
2634                "move",
2635                &path.display().to_string(),
2636                &retained.display().to_string(),
2637            ],
2638        ) {
2639            // A locked worktree or one with submodules cannot always be moved.
2640            // Restore the branch name so a failed move does not strand a retry.
2641            let rollback =
2642                self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2643            let detail = rollback
2644                .err()
2645                .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2646                .unwrap_or_default();
2647            bail!(
2648                "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2649                retained.display(),
2650                error.last_line()
2651            );
2652        }
2653        logdim!(
2654            "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2655            retained.display()
2656        );
2657        Ok(())
2658    }
2659
2660    /// Check a pull request's head out read only, detached, with no branch.
2661    ///
2662    /// Fetches `refs/pull/N/head`, which GitHub serves for every pull request
2663    /// including one from a fork whose branch is not in this repository at all.
2664    /// That is what makes reviewing an outside contribution possible when
2665    /// pushing to it is not.
2666    ///
2667    /// Detached on purpose. Review only mode has nothing to push, and a branch
2668    /// would only invite something to try.
2669    pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2670        let path = self.worktree_path(&format!("review-{number}"));
2671        let local_ref = review_ref(number);
2672        let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2673
2674        self.refuse_review_worktree_changes(number)?;
2675
2676        if let Some(parent) = path.parent() {
2677            std::fs::create_dir_all(parent)
2678                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2679        }
2680        // Removed before the fetch, while `local_ref` still holds the head
2681        // this checkout is at. A force push moves the ref off that commit,
2682        // and the checkout's reflog would then name a commit no ref holds,
2683        // which the removal check rightly keeps as local work.
2684        if !self.remove_review_worktree_at(&path)? {
2685            bail!(
2686                "the existing review worktree for PR #{number} could not be removed safely. Its \
2687                 reference was kept."
2688            );
2689        }
2690
2691        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2692            spar_err!(
2693                "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2694                 every pull request, so this usually means the number is wrong or `origin` does \
2695                 not point at the repository the PR is on.",
2696                e.last_line()
2697            )
2698        })?;
2699
2700        let path_str = path.display().to_string();
2701        self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2702        self.set_up_worktree(&path, &format!("review worktree for PR #{number}"))?;
2703        Ok(path)
2704    }
2705
2706    fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2707        let path = self.worktree_path(&format!("review-{number}"));
2708        if !path.is_dir() {
2709            return Ok(());
2710        }
2711        let local_ref = review_ref(number);
2712        if !self.worktree_belongs_to_repo(&path)? {
2713            return Ok(());
2714        }
2715        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2716            bail!(
2717                "the existing review worktree for PR #{number} has no recorded head at \
2718                 {local_ref}. Refusing to rebuild {}.",
2719                path.display()
2720            );
2721        }
2722        let worktree_head = self.head_oid_checked(&path)?;
2723        let recorded_head = self
2724            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2725            .trim()
2726            .to_string();
2727        if worktree_head != recorded_head {
2728            bail!(
2729                "the existing review worktree for PR #{number} has a local commit that is not on \
2730                 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2731                 {} before retrying.",
2732                path.display()
2733            );
2734        }
2735        self.report_recovery_markers(&path);
2736        self.refuse_dirty_worktree(
2737            &path,
2738            &format!("review worktree for PR #{number}"),
2739            Disposable::ReviewOutput,
2740        )?;
2741        Ok(())
2742    }
2743
2744    /// Read out what an earlier run refused to explain away, before rebuilding
2745    /// over it.
2746    ///
2747    /// The marker is the only record of why a call's answer was thrown out, and
2748    /// a review checkout is rebuilt on the next run. Saying it here is what
2749    /// keeps the file from being the thing that quietly blocks the command
2750    /// instead.
2751    fn report_recovery_markers(&self, path: &Path) {
2752        let Ok(entries) = std::fs::read_dir(path) else {
2753            return;
2754        };
2755        for entry in entries.flatten() {
2756            let name = entry.file_name();
2757            let Some(name) = name.to_str() else { continue };
2758            if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2759                continue;
2760            }
2761            match std::fs::read_to_string(entry.path()) {
2762                Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2763                Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2764            }
2765        }
2766    }
2767
2768    /// A checkout of HEAD for calls that only read, made fresh for them.
2769    ///
2770    /// With `[loop] worktrees` off the calls stay in the shared checkout,
2771    /// which is what that setting asks of every call, and so they do when
2772    /// nothing is committed yet, since there is no commit to check out.
2773    pub fn read_checkout(&self, label: &str) -> Result<ReadCheckout<'_>> {
2774        let shared = || ReadCheckout {
2775            repo: self,
2776            path: self.root.clone(),
2777            checkpoint: None,
2778        };
2779        if !self.isolate_reads {
2780            return Ok(shared());
2781        }
2782        let Some(head) = resolve_optional_commit(&self.root, "HEAD")? else {
2783            return Ok(shared());
2784        };
2785        static NEXT: AtomicUsize = AtomicUsize::new(0);
2786        let path = loop {
2787            let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2788            let path = self.worktree_path(&format!(
2789                "{READ_CHECKOUT_PREFIX}{}-{serial}",
2790                std::process::id()
2791            ));
2792            if !path
2793                .try_exists()
2794                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2795            {
2796                break path;
2797            }
2798        };
2799        if let Some(parent) = path.parent() {
2800            std::fs::create_dir_all(parent)
2801                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2802        }
2803        let path_str = path.display().to_string();
2804        self.git(&["worktree", "add", "--detach", &path_str, &head])
2805            .map_err(|e| {
2806                spar_err!(
2807                    "could not check HEAD out for the {label}: {}",
2808                    e.last_line()
2809                )
2810            })?;
2811        let ready = self
2812            .set_up_worktree(&path, &format!("read-only checkout for the {label}"))
2813            .and_then(|()| self.worktree_checkpoint(&path));
2814        match ready {
2815            Ok(checkpoint) => Ok(ReadCheckout {
2816                repo: self,
2817                path,
2818                checkpoint: Some(checkpoint),
2819            }),
2820            Err(e) => {
2821                // No call has run in it yet, so nothing in it is anybody's.
2822                self.discard_read_checkout(&path);
2823                Err(e)
2824            }
2825        }
2826    }
2827
2828    /// Remove a read checkout once nothing in it has changed since setup.
2829    ///
2830    /// What can be left after that is ignored output from calls that were
2831    /// allowed to leave it, in a checkout nobody else works in, so it goes
2832    /// with the checkout. A recovery marker means a call's answer was already
2833    /// refused over what it did there, and that error said where to look.
2834    fn release_read_checkout(&self, path: &Path, checkpoint: &WorktreeCheckpoint) {
2835        if self.keep_worktrees {
2836            logdim!("kept the read-only checkout at {}", path.display());
2837            return;
2838        }
2839        if has_recovery_marker(path) {
2840            logdim!(
2841                "kept the read-only checkout at {} for recovery",
2842                path.display()
2843            );
2844            return;
2845        }
2846        if let Err(e) = self.require_unchanged_worktree(path, checkpoint, "read-only checkout") {
2847            logwarn!("{e}");
2848            return;
2849        }
2850        self.discard_read_checkout(path);
2851    }
2852
2853    fn discard_read_checkout(&self, path: &Path) {
2854        match self.remove_worktree_at_checked(path, Disposable::ReviewOutput) {
2855            Ok(true) => {}
2856            Ok(false) => logdim!(
2857                "kept the read-only checkout at {} because it was still there after removal",
2858                path.display()
2859            ),
2860            Err(e) => logdim!(
2861                "kept the read-only checkout at {}: {}",
2862                path.display(),
2863                e.last_line()
2864            ),
2865        }
2866    }
2867
2868    /// A worktree for one part of a split, on a new branch off `start`.
2869    ///
2870    /// `start` is the base branch for independent parts and the previous part's
2871    /// branch for stacked ones, which is the only difference between the two
2872    /// shapes at this level.
2873    ///
2874    /// The branch is whatever name was free, which is why it is returned rather
2875    /// than derived by the caller. Splitting the same pull request a second
2876    /// time would otherwise target the branch behind the first run's pull
2877    /// request. Split pushes are create-only and would refuse that target, but
2878    /// a repeated split still needs distinct branches rather than a name that
2879    /// can never be created.
2880    pub fn worktree_for_split(
2881        &self,
2882        parent: i64,
2883        index: usize,
2884        start: &str,
2885    ) -> Result<(PathBuf, String)> {
2886        let slot = self.free_split_slot(parent, index)?;
2887        let branch = format!("{}{slot}", self.branch_prefix);
2888        let path = self.worktree_path(&slot);
2889
2890        if let Some(dir) = path.parent() {
2891            std::fs::create_dir_all(dir)
2892                .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2893        }
2894        self.refuse_dirty_worktree(
2895            &path,
2896            &format!("worktree for part {index} of PR #{parent}"),
2897            Disposable::BuildOutput,
2898        )?;
2899        // The name is free, so there is no branch to delete. A directory can
2900        // still be in the way, left by a worktree that was pruned from git's
2901        // records without being removed from disk.
2902        if !self.remove_worktree_at(&path)? {
2903            bail!(
2904                "the existing worktree for part {index} of PR #{parent} could not be removed \
2905                 safely. No branch was created."
2906            );
2907        }
2908
2909        let path_str = path.display().to_string();
2910        self.git(&["worktree", "add", "-b", &branch, &path_str, start])
2911            .map_err(|e| {
2912                spar_err!(
2913                    "could not create a worktree for part {index} of #{parent}. {}",
2914                    e.last_line()
2915                )
2916            })?;
2917        // Recorded before anything else can fail. An unrecorded branch is one
2918        // `prune_branches` will never remove.
2919        self.record_branch(&branch, "split", parent);
2920        self.set_up_worktree(&path, &format!("worktree for part {index} of PR #{parent}"))?;
2921        Ok((path, branch))
2922    }
2923
2924    /// The first part branch nothing is already sitting on.
2925    ///
2926    /// Origin as well as local, because a part's branch outlives the local one:
2927    /// a second split of the same pull request finds its own earlier branches
2928    /// deleted here but alive on origin, where the pull requests that reviewed
2929    /// them still point at them.
2930    fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2931        for attempt in 1..=SPLIT_SLOTS {
2932            let slot = split_slot(parent, index, attempt);
2933            let branch = format!("{}{slot}", self.branch_prefix);
2934            self.git_try(&["fetch", "origin", &branch]);
2935            if !self.rev_exists(&self.root, &branch)
2936                && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2937            {
2938                return Ok(slot);
2939            }
2940        }
2941        bail!(
2942            "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2943             names are all taken. Inspect the existing branches and child pull requests. Finish \
2944             recording the earlier split, or remove every retained local worktree and branch, \
2945             child pull request, and remote split branch before starting over.",
2946            self.branch_for_split(parent, index)
2947        )
2948    }
2949
2950    /// Whether a previous attempt pushed any branch for this split.
2951    ///
2952    /// The parent comment is the normal retry marker. A branch is the fallback
2953    /// when that comment or the pull request creation failed after the push.
2954    /// Reading origin directly makes the guard survive a fresh clone.
2955    pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2956        let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2957        Ok(!self
2958            .git(&["ls-remote", "--heads", "origin", &pattern])?
2959            .trim()
2960            .is_empty())
2961    }
2962
2963    /// Throw one part away: its worktree, its branch, and its record.
2964    ///
2965    /// For a part that would not stand on its own. Nothing has been pushed at
2966    /// that point, so this leaves no trace anywhere but the log. Takes what
2967    /// `worktree_for_split` returned, since the name it settled on is not
2968    /// derivable from the parent and the index.
2969    pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2970        match self.branch_deletion_is_safe(branch) {
2971            Ok(true) => {}
2972            Ok(false) => {
2973                logdim!(
2974                    "kept {branch} and {} because no surviving ref preserves its tip",
2975                    dir.display()
2976                );
2977                return;
2978            }
2979            Err(error) => {
2980                logdim!(
2981                    "kept {branch} and {} because preservation could not be verified: {}",
2982                    dir.display(),
2983                    error.last_line()
2984                );
2985                return;
2986            }
2987        }
2988        match self.remove_worktree_at(dir) {
2989            Ok(true) => match self.delete_branch_if_safe(branch) {
2990                Ok(true) => self.forget_branch(branch),
2991                Ok(false) => {
2992                    logdim!("kept {branch} because its tip or reflog changed before deletion")
2993                }
2994                Err(error) => logdim!(
2995                    "kept {branch} because deletion safety could not be rechecked: {}",
2996                    error.last_line()
2997                ),
2998            },
2999            Ok(false) => {}
3000            Err(error) => logdim!(
3001                "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
3002                dir.display(),
3003                error.last_line()
3004            ),
3005        }
3006    }
3007
3008    /// Discard one exact mechanical slice that the split workflow just made.
3009    ///
3010    /// Unlike ordinary release, this intentionally removes an unpushed commit.
3011    /// The caller supplies the exact disposable tip, and every file, worktree,
3012    /// ownership, and ref check must still match before anything is removed.
3013    pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
3014        let record = self.known_branches().get(branch).cloned();
3015        if record.is_none_or(|record| record.kind != "split") {
3016            logdim!("kept {branch} because no split branch record proves ownership");
3017            return false;
3018        }
3019        let local_ref = format!("refs/heads/{branch}");
3020        let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
3021            Ok(value) => value.trim().to_string(),
3022            Err(error) => {
3023                logdim!(
3024                    "kept {branch} because its tip could not be checked: {}",
3025                    error.last_line()
3026                );
3027                return false;
3028            }
3029        };
3030        if expected != disposable_head {
3031            logdim!("kept {branch} because it moved beyond the disposable slice");
3032            return false;
3033        }
3034        match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
3035            Ok(true) => {}
3036            Ok(false) => {
3037                logdim!(
3038                    "kept {branch} because its reflog contains work outside the disposable slice"
3039                );
3040                return false;
3041            }
3042            Err(error) => {
3043                logdim!(
3044                    "kept {branch} because its reflog could not be checked: {}",
3045                    error.last_line()
3046                );
3047                return false;
3048            }
3049        }
3050        match self.head_oid_checked(dir) {
3051            Ok(head) if head == disposable_head => {}
3052            Ok(_) => {
3053                logdim!(
3054                    "kept {branch} and {} because the worktree moved beyond the disposable slice",
3055                    dir.display()
3056                );
3057                return false;
3058            }
3059            Err(error) => {
3060                logdim!(
3061                    "kept {branch} and {} because its head could not be checked: {}",
3062                    dir.display(),
3063                    error.last_line()
3064                );
3065                return false;
3066            }
3067        }
3068        match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
3069            Ok(true) => {}
3070            Ok(false) => return false,
3071            Err(error) => {
3072                logdim!(
3073                    "kept {branch} and {} because the disposable slice could not be verified: {}",
3074                    dir.display(),
3075                    error.last_line()
3076                );
3077                return false;
3078            }
3079        }
3080        if let Err(error) =
3081            self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
3082        {
3083            logdim!(
3084                "kept {branch} because its exact disposable tip could not be deleted: {}",
3085                error.last_line()
3086            );
3087            return false;
3088        }
3089        match self.exact_ref_exists_checked(&self.root, &local_ref) {
3090            Ok(false) => {
3091                self.forget_branch(branch);
3092                true
3093            }
3094            Ok(true) => {
3095                logdim!("kept {branch} because its ref still exists after deletion");
3096                false
3097            }
3098            Err(error) => {
3099                logdim!(
3100                    "kept the branch record for {branch} because deletion could not be verified: {}",
3101                    error.last_line()
3102                );
3103                false
3104            }
3105        }
3106    }
3107
3108    pub fn release_review_worktree(&self, number: i64) {
3109        let path = self.worktree_path(&format!("review-{number}"));
3110        match self.review_ref_deletion_is_safe(number) {
3111            Ok(true) => {}
3112            Ok(false) => {
3113                logdim!(
3114                    "kept {} because no surviving ref preserves its review history",
3115                    path.display()
3116                );
3117                return;
3118            }
3119            Err(error) => {
3120                logdim!(
3121                    "kept {} because review history could not be verified: {}",
3122                    path.display(),
3123                    error.last_line()
3124                );
3125                return;
3126            }
3127        }
3128        match self.remove_worktree_at(&path) {
3129            Ok(true) => match self.delete_review_ref_if_safe(number) {
3130                Ok(true) => {}
3131                Ok(false) => logdim!(
3132                    "kept {} because its review history changed before deletion",
3133                    review_ref(number)
3134                ),
3135                Err(error) => logdim!(
3136                    "kept {} because deletion safety could not be rechecked: {}",
3137                    review_ref(number),
3138                    error.last_line()
3139                ),
3140            },
3141            Ok(false) => {}
3142            Err(error) => logdim!(
3143                "kept {} because removal did not reach a confirmed quiet point: {}",
3144                path.display(),
3145                error.last_line()
3146            ),
3147        }
3148    }
3149
3150    /// Release a read-only review checkout only when every observed part of
3151    /// its Git state still matches the checkpoint captured before the calls.
3152    pub(crate) fn release_review_worktree_checked(
3153        &self,
3154        number: i64,
3155        checkpoint: &WorktreeCheckpoint,
3156    ) -> Result<()> {
3157        let path = self.worktree_path(&format!("review-{number}"));
3158        self.require_unchanged_worktree(
3159            &path,
3160            checkpoint,
3161            &format!("review worktree for PR #{number}"),
3162        )?;
3163        if !self.review_ref_deletion_is_safe(number)? {
3164            bail!(
3165                "the review reference for PR #{number} has reflog-only recovery history. The \
3166                 worktree and reference were kept."
3167            );
3168        }
3169        if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
3170            bail!(
3171                "the verified review worktree at {} could not be removed, so its reference was \
3172                 kept",
3173                path.display()
3174            );
3175        }
3176        if !self.delete_review_ref_if_safe(number)? {
3177            bail!(
3178                "the review reference for PR #{number} changed before deletion. The reference was \
3179                 kept."
3180            );
3181        }
3182        Ok(())
3183    }
3184
3185    pub fn release_pr_worktree(&self, number: i64) -> bool {
3186        let path = self.worktree_path(&format!("pr-{number}"));
3187        let local = self.branch_for_pr(number);
3188        match self.branch_deletion_is_safe(&local) {
3189            Ok(true) => {}
3190            Ok(false) => {
3191                logdim!(
3192                    "kept {local} and {} because no surviving ref preserves its tip",
3193                    path.display()
3194                );
3195                return false;
3196            }
3197            Err(error) => {
3198                logdim!(
3199                    "kept {local} and {} because preservation could not be verified: {}",
3200                    path.display(),
3201                    error.last_line()
3202                );
3203                return false;
3204            }
3205        }
3206        match self.remove_worktree_at(&path) {
3207            Ok(true) => match self.delete_branch_if_safe(&local) {
3208                Ok(true) => {
3209                    self.forget_branch(&local);
3210                    true
3211                }
3212                Ok(false) => {
3213                    logdim!("kept {local} because its tip or reflog changed before deletion");
3214                    false
3215                }
3216                Err(error) => {
3217                    logdim!(
3218                        "kept {local} because deletion safety could not be rechecked: {}",
3219                        error.last_line()
3220                    );
3221                    false
3222                }
3223            },
3224            Ok(false) => false,
3225            Err(error) => {
3226                logdim!(
3227                    "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
3228                    path.display(),
3229                    error.last_line()
3230                );
3231                false
3232            }
3233        }
3234    }
3235
3236    // -- branch state -----------------------------------------------------
3237
3238    /// What to diff against: the remote tracking branch when it resolves, the
3239    /// local branch when it does not.
3240    ///
3241    /// This is not a nicety. Every "did the agent do anything" check hangs off
3242    /// this ref, and `git log` against a ref that does not exist fails silently
3243    /// and reads as "no commits". A checkout whose `origin/main` was never
3244    /// fetched would report every implementation as abandoned and throw the
3245    /// work away.
3246    pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
3247        let remote = format!("origin/{base}");
3248        if self.rev_exists(cwd, &remote) {
3249            return remote;
3250        }
3251        if self.rev_exists(cwd, base) {
3252            logdim!("origin/{base} does not resolve, comparing against local {base}");
3253            return base.to_string();
3254        }
3255        logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
3256        remote
3257    }
3258
3259    fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
3260        let spec = format!("{refname}^{{commit}}");
3261        !self
3262            .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
3263            .trim()
3264            .is_empty()
3265    }
3266
3267    pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
3268        let range = format!("{}..HEAD", self.base_ref(cwd, base));
3269        !self
3270            .git_try_at(Some(cwd), &["log", &range, "--oneline"])
3271            .trim()
3272            .is_empty()
3273    }
3274
3275    fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
3276        let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
3277        Ok(found.lines().any(|line| line.trim() == refname))
3278    }
3279
3280    fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
3281        let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
3282        count.trim().parse::<usize>().map_err(|e| {
3283            spar_err!(
3284                "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
3285                count.trim()
3286            )
3287        })
3288    }
3289
3290    pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
3291        let remote = format!("refs/remotes/origin/{base}");
3292        if self.exact_ref_exists_checked(cwd, &remote)? {
3293            return Ok(remote);
3294        }
3295        let local = format!("refs/heads/{base}");
3296        if self.exact_ref_exists_checked(cwd, &local)? {
3297            return Ok(local);
3298        }
3299        bail!("neither origin/{base} nor local branch {base} resolves")
3300    }
3301
3302    pub(crate) fn commit_count_checked(
3303        &self,
3304        cwd: &Path,
3305        refname: &str,
3306        base: &str,
3307    ) -> Result<usize> {
3308        let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
3309        let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
3310        count.trim().parse::<usize>().map_err(|e| {
3311            spar_err!(
3312                "git returned an invalid commit count for {range}: {:?} ({e})",
3313                count.trim()
3314            )
3315        })
3316    }
3317
3318    pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
3319        Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
3320    }
3321
3322    pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
3323        let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
3324        let head = head.trim().to_string();
3325        if head.is_empty() {
3326            bail!("git returned an empty HEAD for {}", cwd.display());
3327        }
3328        Ok(head)
3329    }
3330
3331    /// Whether a recorded SPAR branch's exact tip is retained by a pull request.
3332    ///
3333    /// Pull request head refs remain available after close or merge, so this is
3334    /// stronger than requiring an open pull request or a live remote branch.
3335    pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
3336        let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
3337        self.local_branch_is_preserved(branch.trim())
3338    }
3339
3340    /// Whether a recorded local branch's exact tip is retained by its pull
3341    /// request head, regardless of which branch is currently checked out.
3342    pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
3343        let known = self.known_branches();
3344        let Some(record) = known.get(branch) else {
3345            return Ok(false);
3346        };
3347        self.branch_is_preserved_checked(branch, record)
3348    }
3349
3350    /// Whether tracked, staged, or non-ignored untracked files are uncommitted.
3351    pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
3352        has_uncommitted_work(cwd)
3353    }
3354
3355    /// Whether removing a worktree would delete any local file Git does not
3356    /// reproduce from its commits, including ignored untracked files.
3357    fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
3358        repository_has_recoverable_work_with(cwd, true, disposable)
3359    }
3360
3361    /// Record ignored artifacts that existed before an editing call.
3362    pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
3363        let attributes = attribute_state(cwd)?;
3364        Ok(WorktreeBaseline {
3365            attributes,
3366            ignored_untracked: ignored_untracked_state(cwd)?,
3367            git_state: safe_git_state(cwd)?,
3368        })
3369    }
3370
3371    /// Capture the Git state of a checkout intended to remain read only while
3372    /// external commands inspect it.
3373    pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
3374        let attributes = attribute_state(cwd)?;
3375        Ok(WorktreeCheckpoint {
3376            path: std::fs::canonicalize(cwd)
3377                .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
3378            attributes,
3379            git_state: safe_git_state(cwd)?,
3380            ignored_untracked: ignored_untracked_state(cwd)?,
3381        })
3382    }
3383
3384    /// Move the files read-only calls created in a review checkout out of it,
3385    /// into `.spar/scratch/pr-<number>-<seconds>/` under the same paths, so the
3386    /// checkout matches its checkpoint again.
3387    ///
3388    /// A reviewer told to keep its scratch in the temporary directory still
3389    /// writes a repro test where the project's test runner will find it, and
3390    /// holding that against the review threw away everything both reviewers
3391    /// had done. What a review judges is the tracked tree, which
3392    /// `require_unchanged_worktree` still compares as strictly as ever, along
3393    /// with every untracked file that was there before. Only a file that was
3394    /// not is moved, and it is moved rather than deleted: it may be the
3395    /// reproduction a finding rests on.
3396    ///
3397    /// Best effort. Whatever this cannot move stays where it is, and the check
3398    /// that follows refuses it exactly as before.
3399    pub(crate) fn set_aside_review_scratch(
3400        &self,
3401        cwd: &Path,
3402        checkpoint: &WorktreeCheckpoint,
3403        number: i64,
3404    ) {
3405        let Ok(after) = probe_again_on_failure(|| ignored_untracked_state(cwd)) else {
3406            return;
3407        };
3408        let created: Vec<PathBuf> = checkpoint
3409            .ignored_untracked
3410            .new_ordinary_paths(&after)
3411            .into_iter()
3412            .filter(|path| {
3413                std::fs::symlink_metadata(cwd.join(path))
3414                    .is_ok_and(|meta| meta.is_file() || meta.file_type().is_symlink())
3415            })
3416            .collect();
3417        if created.is_empty() {
3418            return;
3419        }
3420        let shelf = match self.scratch_shelf(number) {
3421            Ok(shelf) => shelf,
3422            Err(e) => {
3423                logdim!("could not make room to move a reviewer's files aside: {e}");
3424                return;
3425            }
3426        };
3427        let mut moved = Vec::new();
3428        for path in created {
3429            let to = shelf.join(&path);
3430            let result = to
3431                .parent()
3432                .map_or(Ok(()), std::fs::create_dir_all)
3433                .and_then(|()| std::fs::rename(cwd.join(&path), &to));
3434            match result {
3435                Ok(()) => moved.push(path),
3436                Err(e) => logdim!(
3437                    "could not move {} out of the review checkout: {e}",
3438                    cwd.join(&path).display()
3439                ),
3440            }
3441        }
3442        if !moved.is_empty() {
3443            logwarn!(
3444                "PR #{number}: a reviewer left {} file(s) in the review checkout: {}. They were \
3445                 moved to {} and the review went on.",
3446                moved.len(),
3447                list_paths(&moved),
3448                shelf.display()
3449            );
3450        }
3451    }
3452
3453    /// A directory of its own under `.spar/scratch` for what one review phase
3454    /// left behind, so two phases never write over each other's files.
3455    fn scratch_shelf(&self, number: i64) -> Result<PathBuf> {
3456        let scratch = self.root.join(STATE_DIR).join("scratch");
3457        std::fs::create_dir_all(&scratch)
3458            .map_err(|e| spar_err!("could not create {}: {e}", scratch.display()))?;
3459        let stem = format!("pr-{number}-{}", crate::clock::now_secs());
3460        for serial in 0..1000 {
3461            let name = match serial {
3462                0 => stem.clone(),
3463                n => format!("{stem}-{n}"),
3464            };
3465            let shelf = scratch.join(name);
3466            match std::fs::create_dir(&shelf) {
3467                Ok(()) => return Ok(shelf),
3468                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
3469                Err(e) => return Err(spar_err!("could not create {}: {e}", shelf.display())),
3470            }
3471        }
3472        bail!(
3473            "could not choose a free directory under {}",
3474            scratch.display()
3475        )
3476    }
3477
3478    /// Require a read-only checkout to match a previously captured checkpoint.
3479    /// Any probe failure is an error because deletion cannot be proven safe.
3480    pub(crate) fn require_unchanged_worktree(
3481        &self,
3482        cwd: &Path,
3483        checkpoint: &WorktreeCheckpoint,
3484        label: &str,
3485    ) -> Result<()> {
3486        let resolved = std::fs::canonicalize(cwd).map_err(|e| {
3487            crate::error::SparError::uncertain_write(format!(
3488                "could not resolve the {label} at {} after inspection: {e}. It was kept.",
3489                cwd.display()
3490            ))
3491        })?;
3492        if resolved != checkpoint.path {
3493            return Err(uncertain_worktree_change(
3494                cwd,
3495                format!(
3496                    "the {label} moved from {} to {} during inspection. It was kept.",
3497                    checkpoint.path.display(),
3498                    resolved.display()
3499                ),
3500            ));
3501        }
3502        let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
3503            uncertain_worktree_change(
3504                cwd,
3505                format!(
3506                    "could not verify attribute files in the {label} at {}: {}. It was kept.",
3507                    cwd.display(),
3508                    e.last_line()
3509                ),
3510            )
3511        })?;
3512        if attributes != checkpoint.attributes {
3513            return Err(uncertain_worktree_change(
3514                cwd,
3515                format!(
3516                    "attribute files in the {label} at {} changed during inspection. It was \
3517                     kept for recovery.",
3518                    cwd.display()
3519                ),
3520            ));
3521        }
3522        let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
3523            uncertain_worktree_change(
3524                cwd,
3525                format!(
3526                    "could not verify the Git state of the {label} at {}: {}. It was kept.",
3527                    cwd.display(),
3528                    e.last_line()
3529                ),
3530            )
3531        })?;
3532        let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
3533            uncertain_worktree_change(
3534                cwd,
3535                format!(
3536                    "could not verify untracked files in the {label} at {}: {}. It was kept.",
3537                    cwd.display(),
3538                    e.last_line()
3539                ),
3540            )
3541        })?;
3542        let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
3543        // Said even when the inspection is accepted, for the same reason it is
3544        // said on an agent call: a path the project ignores is out of scope,
3545        // not unnoticed.
3546        note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
3547        let tracked = checkpoint.git_state.describe_difference(&git_state);
3548        let untracked = (!leavings.condemning.is_empty()).then(|| {
3549            format!(
3550                "untracked file(s) changed: {}",
3551                list_paths(&leavings.condemning)
3552            )
3553        });
3554        let Some(reason) = join_reasons(tracked, untracked) else {
3555            return Ok(());
3556        };
3557        Err(uncertain_worktree_change(
3558            cwd,
3559            format!(
3560                "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
3561                 for recovery.",
3562                cwd.display()
3563            ),
3564        ))
3565    }
3566
3567    /// Accept project-ignored files left by a successful editing call.
3568    /// Ordinary untracked files must already be represented by the commit.
3569    /// Acceptance does not authorize cleanup of the ignored files.
3570    pub(crate) fn refuse_new_ignored_files(
3571        &self,
3572        cwd: &Path,
3573        baseline: &WorktreeBaseline,
3574    ) -> Result<()> {
3575        self.allow_ignored_files(cwd, baseline).map(drop)
3576    }
3577
3578    /// Preserve unexpected output when the editing call did not finish.
3579    pub(crate) fn refuse_failed_edit_leavings(
3580        &self,
3581        cwd: &Path,
3582        baseline: &WorktreeBaseline,
3583    ) -> Result<()> {
3584        self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
3585            .map(drop)
3586    }
3587
3588    /// Reject ordinary untracked changes after a read-only call.
3589    pub(crate) fn refuse_read_only_leavings(
3590        &self,
3591        cwd: &Path,
3592        baseline: &WorktreeBaseline,
3593    ) -> Result<()> {
3594        self.refuse_changed_attributes(cwd, baseline)?;
3595        let after = ignored_untracked_state(cwd).map_err(|e| {
3596            uncertain_worktree_change(
3597                cwd,
3598                format!(
3599                    "could not verify untracked files in {} after a read-only call: {}. The \
3600                     worktree was kept for recovery.",
3601                    cwd.display(),
3602                    e.last_line()
3603                ),
3604            )
3605        })?;
3606        let leavings = baseline.ignored_untracked.read_only_leavings(&after);
3607        if leavings.condemning.is_empty() {
3608            return Ok(());
3609        }
3610        Err(uncertain_worktree_change(
3611            cwd,
3612            format!(
3613                "the read-only call left untracked file(s) in {} that the project does not \
3614                 ignore: {}. The worktree was kept for recovery.",
3615                cwd.display(),
3616                list_paths(&leavings.condemning)
3617            ),
3618        ))
3619    }
3620
3621    /// The ignored paths the check let through, for one report per attempt
3622    /// rather than one per check.
3623    fn allow_ignored_files(
3624        &self,
3625        cwd: &Path,
3626        baseline: &WorktreeBaseline,
3627    ) -> Result<AllowedArtifacts> {
3628        self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
3629    }
3630
3631    fn check_new_ignored_files(
3632        &self,
3633        cwd: &Path,
3634        baseline: &WorktreeBaseline,
3635        allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
3636    ) -> Result<AllowedArtifacts> {
3637        self.refuse_changed_attributes(cwd, baseline)?;
3638        let after = ignored_untracked_state(cwd).map_err(|e| {
3639            uncertain_worktree_change(
3640                cwd,
3641                format!(
3642                    "could not verify untracked files in {} after editing: {}. The worktree was \
3643                     kept for recovery.",
3644                    cwd.display(),
3645                    e.last_line()
3646                ),
3647            )
3648        })?;
3649        let changed = baseline.ignored_untracked.changed_paths(&after);
3650        if changed.is_empty() {
3651            return Ok(AllowedArtifacts::default());
3652        }
3653        let (ignored, changed): (Vec<_>, Vec<_>) = changed
3654            .into_iter()
3655            .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
3656        if changed.is_empty() {
3657            return Ok(AllowedArtifacts::split(ignored, &after));
3658        }
3659        let listed = list_paths(&changed);
3660        Err(uncertain_worktree_change(
3661            cwd,
3662            format!(
3663                "the editing call created or changed untracked or ignored file(s) in {} that \
3664                 cannot be represented by a managed commit: {listed}. The worktree was kept for \
3665                 recovery.",
3666                cwd.display()
3667            ),
3668        ))
3669    }
3670
3671    /// Existing untracked files belong to the checkout owner, even when a call
3672    /// also produces a valid tracked change. Refuse their modification or
3673    /// deletion before accepting the tracked result. Project-ignored files
3674    /// stay outside the commit, wherever the project puts them.
3675    pub(crate) fn refuse_changed_existing_untracked(
3676        &self,
3677        cwd: &Path,
3678        baseline: &WorktreeBaseline,
3679    ) -> Result<()> {
3680        self.check_changed_existing_untracked(cwd, baseline)
3681            .map(drop)
3682    }
3683
3684    fn allow_changed_ignored_artifacts(
3685        &self,
3686        cwd: &Path,
3687        baseline: &WorktreeBaseline,
3688    ) -> Result<AllowedArtifacts> {
3689        self.check_changed_existing_untracked(cwd, baseline)
3690    }
3691
3692    fn check_changed_existing_untracked(
3693        &self,
3694        cwd: &Path,
3695        baseline: &WorktreeBaseline,
3696    ) -> Result<AllowedArtifacts> {
3697        self.refuse_changed_attributes(cwd, baseline)?;
3698        let after = ignored_untracked_state(cwd).map_err(|e| {
3699            uncertain_worktree_change(
3700                cwd,
3701                format!(
3702                    "could not verify existing untracked files in {} after editing: {}. The \
3703                     worktree was kept for recovery.",
3704                    cwd.display(),
3705                    e.last_line()
3706                ),
3707            )
3708        })?;
3709        let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3710        if changed.is_empty() {
3711            return Ok(AllowedArtifacts::default());
3712        }
3713        let (ignored, changed): (Vec<_>, Vec<_>) = changed
3714            .into_iter()
3715            .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3716        if changed.is_empty() {
3717            return Ok(AllowedArtifacts::split(ignored, &after));
3718        }
3719        let listed = list_paths(&changed);
3720        Err(uncertain_worktree_change(
3721            cwd,
3722            format!(
3723                "the editing call changed or deleted existing untracked file(s) in {}: \
3724                 {listed}. The worktree was kept for recovery.",
3725                cwd.display()
3726            ),
3727        ))
3728    }
3729
3730    /// Refuse a byte or mode change that the index did not represent.
3731    ///
3732    /// Clean filters can normalize a working file back to its existing blob,
3733    /// and index flags can hide a change from porcelain status. Comparing the
3734    /// actual tracked files on both sides keeps those bytes from being treated
3735    /// as disposable just because Git has no diff for them.
3736    pub(crate) fn refuse_unrepresented_tracked_changes(
3737        &self,
3738        cwd: &Path,
3739        baseline: &WorktreeBaseline,
3740    ) -> Result<()> {
3741        self.refuse_changed_attributes(cwd, baseline)?;
3742        let after = safe_git_state(cwd).map_err(|e| {
3743            uncertain_worktree_change(
3744                cwd,
3745                format!(
3746                    "could not verify tracked files in {} after editing: {}. The worktree was \
3747                     kept for recovery.",
3748                    cwd.display(),
3749                    e.last_line()
3750                ),
3751            )
3752        })?;
3753        let mut changed = Vec::new();
3754        let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3755            uncertain_worktree_change(
3756                cwd,
3757                format!(
3758                    "could not record untracked files before verifying transformed content in {}: \
3759                     {}. The worktree was kept for recovery.",
3760                    cwd.display(),
3761                    e.last_line()
3762                ),
3763            )
3764        })?;
3765        let mut filter_was_run = false;
3766        let mut filter_problem = None;
3767        let mut repositories: BTreeSet<PathBuf> =
3768            baseline.git_state.repositories.keys().cloned().collect();
3769        repositories.extend(after.repositories.keys().cloned());
3770        'repositories: for repository_path in repositories {
3771            let before_repository = baseline.git_state.repositories.get(&repository_path);
3772            let after_repository = after.repositories.get(&repository_path);
3773            if before_repository.is_none() || after_repository.is_none() {
3774                changed.push(repository_path.clone());
3775                continue;
3776            }
3777            if before_repository.map(|repository| &repository.gitlinks)
3778                != after_repository.map(|repository| &repository.gitlinks)
3779            {
3780                changed.push(repository_path.join("<gitlinks>"));
3781            }
3782            let mut paths = BTreeSet::new();
3783            if let Some(repository) = before_repository {
3784                paths.extend(repository.tracked.keys().cloned());
3785            }
3786            if let Some(repository) = after_repository {
3787                paths.extend(repository.tracked.keys().cloned());
3788            }
3789            for path in paths {
3790                let before = before_repository.and_then(|repository| repository.tracked.get(&path));
3791                let current = after_repository.and_then(|repository| repository.tracked.get(&path));
3792                let worktree_changed =
3793                    before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
3794                let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
3795                    != current.map(|entry| (&entry.index_mode, &entry.index_oid));
3796                if !worktree_changed {
3797                    continue;
3798                }
3799                if !index_changed {
3800                    changed.push(repository_path.join(&path));
3801                    continue;
3802                }
3803                let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
3804                let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
3805                let Some(current_entry) = current else {
3806                    continue;
3807                };
3808                let Some(current_worktree) = current_worktree else {
3809                    continue;
3810                };
3811                let content_changed =
3812                    before_worktree.map(|file| file.content) != Some(current_worktree.content);
3813                let mode_changed = before_worktree.map(|file| file.mode.as_str())
3814                    != Some(current_worktree.mode.as_str());
3815                let repository = cwd.join(&repository_path);
3816                let represented_content = if content_changed {
3817                    filter_was_run = true;
3818                    let result =
3819                        filtered_index_content(&repository, &path, &current_entry.index_oid);
3820                    self.refuse_changed_attributes(cwd, baseline)?;
3821                    match result {
3822                        Ok(expected) => expected == current_worktree.content,
3823                        Err(error) => {
3824                            filter_problem = Some(format!(
3825                                "could not verify transformed content for {:?}: {}",
3826                                repository_path.join(&path),
3827                                error.last_line()
3828                            ));
3829                            false
3830                        }
3831                    }
3832                } else {
3833                    true
3834                };
3835                let represented_mode =
3836                    !mode_changed || current_worktree.mode == current_entry.index_mode;
3837                if !represented_content || !represented_mode {
3838                    changed.push(repository_path.join(&path));
3839                }
3840                if filter_problem.is_some() {
3841                    break 'repositories;
3842                }
3843            }
3844        }
3845        if filter_was_run {
3846            self.refuse_changed_attributes(cwd, baseline)?;
3847            let verified = safe_git_state(cwd).map_err(|e| {
3848                uncertain_worktree_change(
3849                    cwd,
3850                    format!(
3851                        "could not recheck tracked files after verifying transformed content in \
3852                         {}: {}. The worktree was kept for recovery.",
3853                        cwd.display(),
3854                        e.last_line()
3855                    ),
3856                )
3857            })?;
3858            let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3859                uncertain_worktree_change(
3860                    cwd,
3861                    format!(
3862                        "could not recheck untracked files after verifying transformed content \
3863                         in {}: {}. The worktree was kept for recovery.",
3864                        cwd.display(),
3865                        e.last_line()
3866                    ),
3867                )
3868            })?;
3869            if verified != after
3870                || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3871            {
3872                return Err(uncertain_worktree_change(
3873                    cwd,
3874                    "a content filter changed the worktree while SPAR verified the managed \
3875                     commit. The worktree was kept for recovery.",
3876                ));
3877            }
3878            self.refuse_changed_existing_untracked(cwd, baseline)?;
3879        }
3880        if let Some(problem) = filter_problem {
3881            return Err(uncertain_worktree_change(
3882                cwd,
3883                format!("{problem}. The worktree was kept for recovery."),
3884            ));
3885        }
3886        if changed.is_empty() {
3887            return Ok(());
3888        }
3889        let listed = list_paths(&changed);
3890        Err(uncertain_worktree_change(
3891            cwd,
3892            format!(
3893                "the editing call changed tracked working-file bytes, modes, repositories, or \
3894                 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3895                 recovery."
3896            ),
3897        ))
3898    }
3899
3900    pub(crate) fn refuse_changed_attributes(
3901        &self,
3902        cwd: &Path,
3903        baseline: &WorktreeBaseline,
3904    ) -> Result<()> {
3905        let after = attribute_state(cwd).map_err(|e| {
3906            uncertain_worktree_change(
3907                cwd,
3908                format!(
3909                    "could not verify attribute files in {} after editing: {}. The worktree was \
3910                     kept for recovery.",
3911                    cwd.display(),
3912                    e.last_line()
3913                ),
3914            )
3915        })?;
3916        if after == baseline.attributes {
3917            return Ok(());
3918        }
3919        Err(uncertain_worktree_change(
3920            cwd,
3921            format!(
3922                "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
3923                 refused to run a Git operation that could select a new external filter.",
3924                cwd.display()
3925            ),
3926        ))
3927    }
3928
3929    /// Commit a successful editing call from the trusted harness process.
3930    ///
3931    /// Editing sandboxes only need the working tree. They never need writable
3932    /// access to the repository's object database, refs, config, or hooks.
3933    pub(crate) fn commit_pending_changes(
3934        &self,
3935        cwd: &Path,
3936        baseline: &WorktreeBaseline,
3937        preferred_subject: &str,
3938        fallback_subject: &str,
3939    ) -> Result<bool> {
3940        let mut artifacts = IgnoredArtifacts::default();
3941        self.refuse_changed_attributes(cwd, baseline)?;
3942        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3943        refuse_unsafe_index_flags(cwd)?;
3944        if !self.has_uncommitted_changes(cwd)? {
3945            artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3946            artifacts.report(cwd);
3947            return Ok(false);
3948        }
3949        self.stage_managed_changes(cwd, baseline).map_err(|e| {
3950            e.with_message(format!(
3951                "could not stage changes in {}: {}",
3952                cwd.display(),
3953                e.last_line()
3954            ))
3955        })?;
3956        // Git ignore rules decide which files stay outside the commit.
3957        // Whether cleanup can delete them is a separate decision.
3958        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3959        let changed_gitlinks = changed_staged_gitlinks(cwd)?;
3960        if !changed_gitlinks.is_empty() {
3961            let listed = list_paths(&changed_gitlinks);
3962            bail!(
3963                "the editing call added or changed a gitlink at {listed}. It was staged but not \
3964                 committed because the referenced repository objects might exist only inside \
3965                 this worktree. The worktree was kept for recovery."
3966            );
3967        }
3968        let mut subject = self.clean_title(preferred_subject)?;
3969        if subject.trim().is_empty() {
3970            subject = self.clean_title(fallback_subject)?;
3971        }
3972        self.commit_staged_changes(cwd, &subject).map_err(|e| {
3973            e.with_message(format!(
3974                "could not commit changes in {}: {}. The staged files were kept.",
3975                cwd.display(),
3976                e.last_line()
3977            ))
3978        })?;
3979        if has_tracked_or_staged_work(cwd)? {
3980            bail!(
3981                "the commit in {} left additional uncommitted files. They were kept for \
3982                 recovery.",
3983                cwd.display()
3984            );
3985        }
3986        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3987        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3988        artifacts.report(cwd);
3989        Ok(true)
3990    }
3991
3992    fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
3993        let after = ignored_untracked_state(cwd)?;
3994        self.git_at_without_automation(cwd, &["add", "-u"])?;
3995        let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
3996        if paths.is_empty() {
3997            return Ok(());
3998        }
3999        let mut input = Vec::new();
4000        for path in paths {
4001            input.extend(os_str_bytes(path.as_os_str())?);
4002            input.push(0);
4003        }
4004        let argv = git_without_automation_argv(&[
4005            "--literal-pathspecs",
4006            "add",
4007            "--pathspec-from-file=-",
4008            "--pathspec-file-nul",
4009        ]);
4010        proc::run_with_input_bytes(
4011            &argv,
4012            &self.git_opts(Some(cwd), true).stop_descendants(true),
4013            &input,
4014        )?;
4015        Ok(())
4016    }
4017
4018    /// Commit an index prepared by the parent without signing, hooks, or
4019    /// inherited repository automation.
4020    pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
4021        self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
4022            .map(|_| ())
4023    }
4024
4025    /// How many commits `refname` carries that the base does not.
4026    ///
4027    /// Counted from the commits themselves rather than from `commit_subjects`,
4028    /// which drops a commit whose message is empty. The guards in
4029    /// `worktree_add` decide whether to delete a branch on this number, and an
4030    /// empty message must not read as an empty branch.
4031    pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
4032        let range = format!("{}..{refname}", self.base_ref(cwd, base));
4033        self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
4034            .trim()
4035            .parse()
4036            .unwrap_or(0)
4037    }
4038
4039    /// One `hash subject` line per commit `refname` carries that the base does
4040    /// not, oldest first. For showing a person what is on a branch, so the
4041    /// hash keeps a commit with no message from listing as nothing.
4042    pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
4043        let range = format!("{}..{refname}", self.base_ref(cwd, base));
4044        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
4045            .lines()
4046            .map(str::to_string)
4047            .collect()
4048    }
4049
4050    /// The commits `later` carries that `earlier` does not, oldest first, when
4051    /// `earlier` is genuinely behind it.
4052    ///
4053    /// `None` when it is not an ancestor, which is not the same as nothing
4054    /// having landed. `rewrite_commits_if_needed` rewrites hashes from the first
4055    /// offending commit spar made onward, so a head recorded part way through a
4056    /// round can still be a readable object and no longer be on the branch. `git log` answers that
4057    /// with every commit on the branch, so without the check the one caller
4058    /// would report the whole branch as unread, which is the widest possible
4059    /// wrong answer.
4060    ///
4061    /// No `base_ref` resolution, unlike its neighbours: these are commits rather
4062    /// than branch names, and putting a sha through it logs a fallback line
4063    /// every time.
4064    pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
4065        let ancestor = self
4066            .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
4067            .is_ok();
4068        if !ancestor {
4069            return None;
4070        }
4071        let range = format!("{earlier}..{later}");
4072        Some(
4073            self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
4074                .lines()
4075                .map(str::to_string)
4076                .collect(),
4077        )
4078    }
4079
4080    /// The subjects of the commits `refname` carries that the base does not,
4081    /// oldest first.
4082    pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
4083        let range = format!("{}..{refname}", self.base_ref(cwd, base));
4084        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
4085            .lines()
4086            .map(str::trim)
4087            .filter(|line| !line.is_empty())
4088            .map(str::to_string)
4089            .collect()
4090    }
4091
4092    /// The paths this checkout changes relative to the base, sorted.
4093    ///
4094    /// A three dot range, matching `diff_stat`: what the branch did, not what
4095    /// the base has done since.
4096    ///
4097    /// `--no-renames` because a rename reported as its destination alone leaves
4098    /// the source out of the list, and a part carrying only the destination
4099    /// would be a copy. As a deletion and an addition it is two paths, which a
4100    /// part can carry together or leave to the leftover report.
4101    ///
4102    /// `-z` because without it git writes paths for display: anything
4103    /// non-ASCII comes back escaped and wrapped in quotes, and that string is
4104    /// not a path. A part built from one carries a pathspec matching no file,
4105    /// so the file never reaches the slice while every list still says the part
4106    /// took it. It also keeps a path with a space at either end intact.
4107    pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
4108        let range = format!("{}...HEAD", self.base_ref(cwd, base));
4109        self.git_try_at(
4110            Some(cwd),
4111            &["diff", "--name-only", "--no-renames", "-z", &range],
4112        )
4113        .split('\0')
4114        .filter(|path| !path.is_empty())
4115        .map(str::to_string)
4116        .collect()
4117    }
4118
4119    /// The lines a line comment can go on: the new side of each hunk of the
4120    /// branch's three dot diff, which is the diff GitHub shows for a pull
4121    /// request.
4122    ///
4123    /// Every flag that shapes the hunks is spelled out as git's own default,
4124    /// which is what GitHub shows, so a user's `diff.noprefix`, `diff.context`,
4125    /// `diff.interHunkContext`, `diff.algorithm` or `diff.submodule` cannot
4126    /// move a line comment onto a line GitHub does not show. `GIT_DIFF_OPTS`
4127    /// is emptied for the same reason: git reads it after the command line,
4128    /// so it would widen every hunk past `--unified=3`. `--no-ext-diff` and
4129    /// `--no-textconv` also matter for more than the shape: the branch being
4130    /// reviewed is somebody else's, and a diff driver or text converter would
4131    /// run a script it changed. A failure is no hunks, which only means every
4132    /// finding stays in the summary.
4133    pub fn diff_hunks(&self, cwd: &Path, base: &str) -> Hunks {
4134        let range = format!("{}...HEAD", self.base_ref(cwd, base));
4135        let argv = git_without_maintenance_argv(&[
4136            "-c",
4137            "core.quotePath=false",
4138            "diff",
4139            "--no-color",
4140            "--no-ext-diff",
4141            "--no-textconv",
4142            "--unified=3",
4143            "--inter-hunk-context=0",
4144            "--diff-algorithm=myers",
4145            "--indent-heuristic",
4146            "--submodule=short",
4147            "--find-renames",
4148            "--src-prefix=a/",
4149            "--dst-prefix=b/",
4150            &range,
4151        ]);
4152        let opts = self.git_opts(Some(cwd), false).env("GIT_DIFF_OPTS", "");
4153        new_side_hunks(&proc::run(&argv, &opts).unwrap_or_default())
4154    }
4155
4156    /// The files one commit range touched, for checking a claim against the
4157    /// diff rather than against the report that made it.
4158    pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
4159        self.git_try_at(
4160            Some(cwd),
4161            &[
4162                "diff",
4163                "--name-only",
4164                "--no-renames",
4165                "-z",
4166                &format!("{from}..{to}"),
4167            ],
4168        )
4169        .split('\0')
4170        .filter(|path| !path.is_empty())
4171        .map(str::to_string)
4172        .collect()
4173    }
4174
4175    /// Where `refname` left the base: the commit its own change is measured
4176    /// from, and the one a slice of that change has to be taken against.
4177    pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
4178        let base_ref = self.base_ref(cwd, base);
4179        let out = self
4180            .git_at(Some(cwd), &["merge-base", &base_ref, refname])
4181            .map_err(|e| {
4182                spar_err!(
4183                    "could not find where {refname} and {base_ref} diverged. {}",
4184                    e.last_line()
4185                )
4186            })?;
4187        let sha = out.trim().to_string();
4188        if sha.is_empty() {
4189            bail!("{refname} and {base_ref} share no history");
4190        }
4191        Ok(sha)
4192    }
4193
4194    pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
4195        let range = format!("{}...HEAD", self.base_ref(cwd, base));
4196        let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
4197        full.trim().to_string()
4198    }
4199
4200    /// Scrub commit messages that slipped past the prompt.
4201    ///
4202    /// `git filter-branch` calls back into this same binary, so there is no
4203    /// interpreter to find and no second copy of the rules to drift.
4204    ///
4205    /// `floor` is the head spar found when it started, and nothing at or below
4206    /// it is rewritten. The style gate is for what spar writes, and a person's
4207    /// commit message is not that: rewriting one changes a published SHA, which
4208    /// diverges the branch under somebody's own checkout and is exactly the
4209    /// history rewrite this design promises never to do. A `None` floor means
4210    /// every commit above the base is spar's own, which is true of a branch
4211    /// spar created in this invocation and of nothing else.
4212    pub fn rewrite_commits_if_needed(
4213        &self,
4214        cwd: &Path,
4215        base: &str,
4216        floor: Option<&str>,
4217    ) -> Result<()> {
4218        let base_ref = self.base_ref(cwd, base);
4219        let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
4220        self.report_commits_left_alone(cwd, &base_ref, floor);
4221
4222        let range = format!("{floor}..HEAD");
4223        let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
4224
4225        let offenders = raw
4226            .split('\x1e')
4227            .filter_map(|entry| entry.split_once('\0'))
4228            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4229            .count();
4230        if offenders == 0 {
4231            return Ok(());
4232        }
4233        logdim!("{offenders} commit message(s) violated style rules, rewriting");
4234
4235        let exe = self_binary()?;
4236        let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
4237
4238        let argv: Vec<String> = [
4239            "git",
4240            "filter-branch",
4241            "-f",
4242            "--msg-filter",
4243            &filter,
4244            &range,
4245        ]
4246        .iter()
4247        .map(|s| s.to_string())
4248        .collect();
4249        let opts = ExecOpts::new()
4250            .cwd(cwd)
4251            .check(false)
4252            .timeout_secs(600)
4253            .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
4254            .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
4255            .env(
4256                "SPAR_BAN_AI_ATTRIBUTION",
4257                bool_env(self.style.ban_ai_attribution),
4258            );
4259        let _ = proc::run(&argv, &opts);
4260
4261        let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
4262        if !style::violations(&after, &self.style).is_empty() {
4263            bail!(
4264                "commit messages still violate style rules after a rewrite in {}.",
4265                cwd.display()
4266            );
4267        }
4268        Ok(())
4269    }
4270
4271    /// Say out loud when a message spar will not touch breaks the style rules.
4272    ///
4273    /// Silence here would read as "there was nothing to scrub", and the point
4274    /// of the floor is that there was something and spar chose to leave it.
4275    fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
4276        if floor == base_ref {
4277            return;
4278        }
4279        let raw = self.git_try_at(
4280            Some(cwd),
4281            &[
4282                "log",
4283                &format!("{base_ref}..{floor}"),
4284                "--format=%H%x00%B%x1e",
4285            ],
4286        );
4287        let left: Vec<&str> = raw
4288            .split('\x1e')
4289            .filter_map(|entry| entry.split_once('\0'))
4290            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4291            .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
4292            .collect();
4293        if left.is_empty() {
4294            return;
4295        }
4296        logdim!(
4297            "{} commit message(s) already on the branch break the style rules and were left \
4298             alone: {}. spar does not rewrite commits it did not write.",
4299            left.len(),
4300            left.join(", ")
4301        );
4302    }
4303
4304    /// Push by explicit refspec from HEAD, leasing the head spar reviewed.
4305    ///
4306    /// A resumed PR is checked out under a local name (`pr-N`) that does not
4307    /// match its remote branch, so pushing by branch name would resolve the
4308    /// wrong local ref or fail outright.
4309    ///
4310    /// `expected` is the remote head spar last read. A bare `--force-with-lease`
4311    /// leases the implicit tracking ref instead, which is shared across every
4312    /// worktree and is updated by any `git fetch`. The editing agent is a real
4313    /// CLI with shell access, and fetching is a normal thing for it to do: one
4314    /// `git fetch` mid round moves the tracking ref to whatever the remote holds
4315    /// and the lease then passes against a head spar never reviewed, which
4316    /// silently drops somebody's commit. Naming the head makes the push refuse
4317    /// exactly that case.
4318    pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
4319        self.push_to(cwd, &PushTarget::Origin, branch, expected)
4320    }
4321
4322    /// `push`, to wherever the branch was first published.
4323    pub fn push_to(
4324        &self,
4325        cwd: &Path,
4326        target: &PushTarget,
4327        branch: &str,
4328        expected: Option<&str>,
4329    ) -> Result<()> {
4330        let expected = expected.map(str::trim).filter(|head| !head.is_empty());
4331        if let Some(head) = expected {
4332            self.refuse_push_that_drops(cwd, head)?;
4333        }
4334        let pushed = self
4335            .git_push(cwd, target, branch, expected)
4336            .map_err(|e| push_error(target, branch, &e));
4337        self.record_write(pushed)
4338    }
4339
4340    /// Publish a branch created in this invocation, from a fork if origin
4341    /// refuses it.
4342    ///
4343    /// Only a new branch can move. A pull request that already exists has its
4344    /// branch where it is, and pushing it anywhere else would not update it. A
4345    /// new one has no pull request yet, so a fork is just where it is opened
4346    /// from, which is how anybody without write access contributes.
4347    ///
4348    /// One write whichever way it goes. The refusal is not counted as a
4349    /// failure when the fork takes the branch: the run did what it set out to,
4350    /// and a failed write makes the exit status say otherwise.
4351    pub fn publish_new_branch(&self, cwd: &Path, branch: &str, base: &str) -> Result<PushTarget> {
4352        let origin = PushTarget::Origin;
4353        let refused = match self.git_push(cwd, &origin, branch, None) {
4354            Ok(()) => return self.record_write(Ok(origin)),
4355            Err(e) if self.fork_when_denied && push_denial(&e.to_string()).is_some() => e,
4356            Err(e) => {
4357                let err = push_error(&origin, branch, &e);
4358                let err = if push_denial(&e.to_string()).is_some() {
4359                    spar_err!(
4360                        "{err} Set fork_when_denied = true under [loop] to publish it from a fork \
4361                         instead."
4362                    )
4363                } else {
4364                    err
4365                };
4366                return self.record_write(Err(err));
4367            }
4368        };
4369        self.record_write(self.publish_on_fork(cwd, branch, base, &refused))
4370    }
4371
4372    fn publish_on_fork(
4373        &self,
4374        cwd: &Path,
4375        branch: &str,
4376        base: &str,
4377        refused: &crate::error::SparError,
4378    ) -> Result<PushTarget> {
4379        let fork = self.fork().map_err(|e| {
4380            spar_err!(
4381                "{} spar could not publish it from a fork instead: {}",
4382                push_error(&PushTarget::Origin, branch, refused),
4383                e.last_line()
4384            )
4385        })?;
4386        let lease = self.fork_branch_lease(&fork, branch, base)?;
4387        let refused = refused.to_string();
4388        log!(
4389            "{} Publishing {branch} from the fork {} instead.",
4390            push_denial(&refused).unwrap_or("origin refused the push."),
4391            fork.full_name
4392        );
4393        let target = PushTarget::Fork(fork);
4394        // Not `push_to`: a lease on a branch a closed pull request left behind
4395        // replaces it, and that branch was never this HEAD's to contain.
4396        self.git_push(cwd, &target, branch, lease.as_deref())
4397            .map_err(|e| push_error(&target, branch, &e))?;
4398        Ok(target)
4399    }
4400
4401    /// What the first push of `branch` to the fork leases against.
4402    ///
4403    /// Nothing when the fork has no such branch. Its tip when a pull request
4404    /// already holds every commit on it beyond `base`, which is what a closed
4405    /// pull request leaves, since GitHub keeps the branch on the fork. Anything
4406    /// else is work only that branch has, and is refused the way an issue
4407    /// branch on origin is.
4408    fn fork_branch_lease(&self, fork: &Fork, branch: &str, base: &str) -> Result<Option<String>> {
4409        let live_ref = format!("refs/heads/{branch}");
4410        let unreadable = |e: crate::error::SparError| {
4411            spar_err!(
4412                "could not check whether the fork {} already has {branch}. {}",
4413                fork.full_name,
4414                e.last_line()
4415            )
4416        };
4417        let listed = self
4418            .git(&["ls-remote", "--heads", &fork.url, &live_ref])
4419            .map_err(unreadable)?;
4420        let Some(tip) = remote_head_oid(&listed, &live_ref)? else {
4421            return Ok(None);
4422        };
4423        let local = format!("refs/spar/fork/{branch}");
4424        self.git(&[
4425            "fetch",
4426            "--no-tags",
4427            &fork.url,
4428            &format!("+{live_ref}:{local}"),
4429        ])
4430        .map_err(unreadable)?;
4431        let ahead = self.commit_count_checked(&self.root, &local, base);
4432        let held = matches!(ahead, Ok(0)) || self.pull_request_holds(branch, &local, base);
4433        self.git_try(&["update-ref", "-d", &local]);
4434        let ahead = ahead?;
4435        if !held {
4436            bail!(
4437                "the fork {} already has {branch} with {ahead} commit(s) that are not on {base}, \
4438                 and no pull request accounts for them. Publishing over it would lose that work.\n\
4439                 Delete it with `git push {} --delete {branch}` if it is no longer needed, then \
4440                 run spar again.",
4441                fork.full_name,
4442                fork.url
4443            );
4444        }
4445        Ok(Some(tip))
4446    }
4447
4448    fn git_push(
4449        &self,
4450        cwd: &Path,
4451        target: &PushTarget,
4452        branch: &str,
4453        expected: Option<&str>,
4454    ) -> Result<()> {
4455        let refspec = format!("HEAD:refs/heads/{branch}");
4456        let lease = match (expected, target) {
4457            (Some(head), _) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
4458            // A fork is pushed to by URL, so there is no tracking ref for a
4459            // bare lease to read. Leasing against nothing makes the first push
4460            // a create that never moves a branch already there.
4461            (None, PushTarget::Fork(_)) => format!("--force-with-lease=refs/heads/{branch}:"),
4462            (None, PushTarget::Origin) => "--force-with-lease".to_string(),
4463        };
4464        self.git_at(Some(cwd), &["push", &lease, target.remote(), &refspec])
4465            .map(|_| ())
4466    }
4467
4468    /// The authenticated user's fork of this repository, made if there is none.
4469    ///
4470    /// GitHub answers a request to fork with the fork that already exists, so
4471    /// asking is safe either way, but it is a write and it is only made when
4472    /// looking found nothing. A new fork is copied in the background, so it is
4473    /// waited for before anything is pushed to it.
4474    fn fork(&self) -> Result<Fork> {
4475        match self.existing_fork() {
4476            Ok(Some(fork)) => return Ok(fork),
4477            Ok(None) => {}
4478            Err(e) => logdim!("{e} Asking GitHub to fork it, which returns any fork there is."),
4479        }
4480        let answer = self
4481            .gh(&["api", "-X", "POST", "repos/{owner}/{repo}/forks"])
4482            .map_err(|e| spar_err!("could not fork this repository. {}", e.last_line()))?;
4483        let fork = fork_from_api(&answer, &self.origin_push_url())?;
4484        self.wait_for_fork(&fork)?;
4485        Ok(self.fork.get_or_init(|| fork).clone())
4486    }
4487
4488    /// The authenticated user's fork of this repository, if there is one,
4489    /// found without making one.
4490    fn existing_fork(&self) -> Result<Option<Fork>> {
4491        if let Some(fork) = self.fork.get() {
4492            return Ok(Some(fork.clone()));
4493        }
4494        let answer = self
4495            .gh(&[
4496                "api",
4497                "graphql",
4498                "-F",
4499                "owner={owner}",
4500                "-F",
4501                "name={repo}",
4502                "-f",
4503                "query=query($owner: String!, $name: String!) { repository(owner: $owner, name: \
4504                 $name) { forks(first: 1, affiliations: [OWNER]) { nodes { nameWithOwner owner { \
4505                 login } sshUrl url } } } }",
4506                "--jq",
4507                ".data.repository.forks.nodes",
4508            ])
4509            .map_err(|e| {
4510                spar_err!(
4511                    "could not look for a fork of this repository. {}",
4512                    e.last_line()
4513                )
4514            })?;
4515        let found = fork_from_listing(&answer, &self.origin_push_url())?;
4516        Ok(found.map(|fork| self.fork.get_or_init(|| fork).clone()))
4517    }
4518
4519    /// Whether GitHub says the authenticated user cannot push here. False
4520    /// when it cannot say, so an unanswered question skips the early check
4521    /// rather than guessing.
4522    fn origin_refuses_push(&self) -> bool {
4523        self.gh_try(&["api", "repos/{owner}/{repo}", "--jq", ".permissions.push"])
4524            .trim()
4525            == "false"
4526    }
4527
4528    fn origin_push_url(&self) -> String {
4529        self.git_try(&["remote", "get-url", "--push", "origin"])
4530    }
4531
4532    /// Where spar can push a pull request's branch: origin for a branch in
4533    /// this repository, the fork for one on the authenticated user's own fork,
4534    /// such as one spar opened after origin refused it, and nowhere for
4535    /// anybody else's.
4536    pub fn pr_push_target(&self, pr: &PrView) -> Option<PushTarget> {
4537        if !pr.is_cross_repository {
4538            return Some(PushTarget::Origin);
4539        }
4540        let viewer = self.viewer_login().ok()?;
4541        own_fork_target(pr, viewer, &self.origin_push_url())
4542    }
4543
4544    fn wait_for_fork(&self, fork: &Fork) -> Result<()> {
4545        const TRIES: u32 = 30;
4546        let mut tries = 1;
4547        loop {
4548            match self.git(&["ls-remote", &fork.url, "HEAD"]) {
4549                Ok(_) => return Ok(()),
4550                Err(e) if tries >= TRIES => bail!(
4551                    "the fork {} could not be reached after {} seconds. {}",
4552                    fork.full_name,
4553                    TRIES * 2,
4554                    e.last_line()
4555                ),
4556                Err(_) => {
4557                    tries += 1;
4558                    std::thread::sleep(std::time::Duration::from_secs(2));
4559                }
4560            }
4561        }
4562    }
4563
4564    /// Refuse a push whose HEAD does not contain the head spar reviewed.
4565    ///
4566    /// The lease answers the remote moving. This answers the local side moving:
4567    /// an agent that reset or rebased the worktree leaves a HEAD that would
4568    /// publish over the reviewed head rather than on top of it, and the lease
4569    /// cannot tell the difference because the remote is exactly where spar left
4570    /// it.
4571    fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
4572        let head = self.head_oid_checked(cwd)?;
4573        if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
4574            return Ok(());
4575        }
4576        bail!(
4577            "refusing to push {head} because it does not contain the reviewed head {expected}. \
4578             The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
4579             the intended commits on top of it before resuming.",
4580            cwd.display()
4581        )
4582    }
4583
4584    /// Create one remote branch for a split without ever moving an existing ref.
4585    ///
4586    /// `worktree_for_split` chooses a name that is free locally and on origin,
4587    /// but another writer can still take it before the push. An empty expected
4588    /// value in the lease makes this an atomic create: it creates an absent ref,
4589    /// accepts an identical ref as a no-op, and never moves an existing ref.
4590    /// The shared `push` method cannot be used because its lease permits
4591    /// updating a ref fetched earlier.
4592    pub fn push_split_branch(
4593        &self,
4594        cwd: &Path,
4595        branch: &str,
4596    ) -> std::result::Result<(), SplitPushError> {
4597        let remote_ref = format!("refs/heads/{branch}");
4598        let lease = format!("--force-with-lease={remote_ref}:");
4599        let refspec = format!("HEAD:{remote_ref}");
4600        let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
4601            Ok(_) => Ok(()),
4602            Err(push_error) => {
4603                let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
4604                let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
4605                reconcile_failed_split_push(branch, push_error, local, remote)
4606            }
4607        };
4608        self.record_write(result)
4609    }
4610
4611    // -- gh ---------------------------------------------------------------
4612
4613    pub fn gh(&self, args: &[&str]) -> Result<String> {
4614        self.gh_at(None, args)
4615    }
4616
4617    pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
4618        let mut argv = vec!["gh".to_string()];
4619        argv.extend(args.iter().map(|s| s.to_string()));
4620        proc::run(
4621            &argv,
4622            &ExecOpts::new()
4623                .cwd(cwd.unwrap_or(&self.root))
4624                .timeout_secs(300),
4625        )
4626    }
4627
4628    /// Run gh with something on its stdin.
4629    ///
4630    /// A tracker body is far too long to pass on argv, and `--body-file -` is
4631    /// how gh takes one. `proc::exec` already wires the pipe, so this is a
4632    /// sibling of `gh_at` rather than anything new.
4633    pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
4634        let mut argv = vec!["gh".to_string()];
4635        argv.extend(args.iter().map(|s| s.to_string()));
4636        proc::run(
4637            &argv,
4638            &ExecOpts::new()
4639                .cwd(&self.root)
4640                .timeout_secs(300)
4641                .stdin(stdin),
4642        )
4643    }
4644
4645    pub fn gh_try(&self, args: &[&str]) -> String {
4646        let mut argv = vec!["gh".to_string()];
4647        argv.extend(args.iter().map(|s| s.to_string()));
4648        proc::run(
4649            &argv,
4650            &ExecOpts::new()
4651                .cwd(&self.root)
4652                .check(false)
4653                .timeout_secs(300),
4654        )
4655        .unwrap_or_default()
4656    }
4657
4658    /// `gh api -i`: the status line and headers with the body, and the exit
4659    /// code ignored on purpose. A `304 Not Modified`, which is the answer a
4660    /// conditional poll wants, makes gh exit 1 while printing exactly what is
4661    /// needed. Only a spawn failure or a timeout is an error here.
4662    pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
4663        let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
4664        for (name, value) in headers {
4665            argv.push("-H".to_string());
4666            argv.push(format!("{name}: {value}"));
4667        }
4668        argv.push(endpoint.to_string());
4669        let out = proc::exec(
4670            &argv,
4671            &ExecOpts::new()
4672                .cwd(&self.root)
4673                .check(false)
4674                .timeout_secs(300),
4675        )?;
4676        parse_api_response(&out.stdout)
4677            .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
4678    }
4679
4680    /// Every open pull request, with what a sweep needs to know about each.
4681    pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
4682        let text = self.gh(&[
4683            "pr",
4684            "list",
4685            "--state",
4686            "open",
4687            "--limit",
4688            "500",
4689            "--json",
4690            PR_SWEEP_FIELDS,
4691        ])?;
4692        serde_json::from_str(&text)
4693            .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
4694    }
4695
4696    /// One pull request, open or not, with what a sweep needs to know.
4697    pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
4698        let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
4699        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4700    }
4701
4702    /// Whether `login` is in a team, named `org/slug` as gh gives a team in
4703    /// `reviewRequests`. GitHub answers 404 for a team the login is not in, and
4704    /// a team the viewer is in is one it can always see. None when GitHub did
4705    /// not answer either way.
4706    pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
4707        // A team asked for a review belongs to the repository's own owner.
4708        let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
4709        if slug.is_empty() || login.is_empty() {
4710            return None;
4711        }
4712        let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
4713        let resp = self.gh_api_raw(&path, &[]).ok()?;
4714        match resp.status {
4715            200 => {
4716                let body: Value = serde_json::from_str(&resp.body).ok()?;
4717                Some(body.get("state")?.as_str()? == "active")
4718            }
4719            404 => Some(false),
4720            _ => None,
4721        }
4722    }
4723
4724    /// Every review request made on a pull request, oldest first. None when
4725    /// GitHub did not answer.
4726    ///
4727    /// A review spar posts as a comment leaves the request it answered open,
4728    /// so `reviewRequests` alone cannot tell that request from one made after
4729    /// the review. When it was made can.
4730    pub fn review_requests_made(&self, number: i64) -> Option<Vec<crate::model::ReviewRequested>> {
4731        let text = self
4732            .gh(&[
4733                "api",
4734                "graphql",
4735                "-F",
4736                "owner={owner}",
4737                "-F",
4738                "repo={repo}",
4739                "-F",
4740                &format!("number={number}"),
4741                "-f",
4742                &format!("query={REQUESTS_QUERY}"),
4743            ])
4744            .ok()?;
4745        parse_review_requests(&text)
4746    }
4747
4748    /// How many commits lie between two SHAs, when GitHub will say.
4749    pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
4750        if from.is_empty() || to.is_empty() {
4751            return None;
4752        }
4753        let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
4754        self.gh_try(&["api", &path, "--jq", ".total_commits"])
4755            .trim()
4756            .parse()
4757            .ok()
4758    }
4759
4760    /// `owner/name`, for the line that says what is being watched.
4761    pub fn repo_name(&self) -> Option<String> {
4762        let name = self.gh_try(&[
4763            "repo",
4764            "view",
4765            "--json",
4766            "nameWithOwner",
4767            "--jq",
4768            ".nameWithOwner",
4769        ]);
4770        let name = name.trim();
4771        (!name.is_empty()).then(|| name.to_string())
4772    }
4773
4774    pub fn listen_state_path(&self) -> PathBuf {
4775        self.root.join(STATE_DIR).join("state").join("listen.json")
4776    }
4777
4778    pub fn listen_lock_path(&self) -> PathBuf {
4779        self.root.join(STATE_DIR).join("state").join("listen.lock")
4780    }
4781
4782    /// The login `gh` is authenticated as.
4783    ///
4784    /// A hard error, never a degradation. Everything spar wrote has to be
4785    /// excluded from what it answers, and custody cannot be read from git
4786    /// authorship, so this is the only thing that tells spar's own comments
4787    /// from somebody else's. Without it the failure is not "answers a bit too
4788    /// much", it is a thread where spar answers itself until somebody notices.
4789    ///
4790    /// Not cached on disk: `gh auth switch` between runs would make a stored
4791    /// answer wrong in exactly the way that produces that thread.
4792    pub fn viewer_login(&self) -> Result<&str> {
4793        if let Some(login) = self.viewer.get() {
4794            return Ok(login);
4795        }
4796        let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
4797        let login = if !rest.trim().is_empty() {
4798            rest.trim().to_string()
4799        } else {
4800            // A token that cannot read /user can still answer for itself in
4801            // GraphQL, which is the case on some Enterprise installs.
4802            self.gh(&[
4803                "api",
4804                "graphql",
4805                "-f",
4806                "query={ viewer { login } }",
4807                "--jq",
4808                ".data.viewer.login",
4809            ])
4810            .map_err(|e| {
4811                spar_err!(
4812                    "could not find out who `gh` is authenticated as, so spar cannot tell its \
4813                     own comments from anybody else's. {}\nRun `gh auth status`.",
4814                    e.last_line()
4815                )
4816            })?
4817            .trim()
4818            .to_string()
4819        };
4820        if login.is_empty() {
4821            bail!("`gh` reported an empty login. Run `gh auth status`.");
4822        }
4823        Ok(self.viewer.get_or_init(|| login))
4824    }
4825
4826    /// One issue as it stands, open or closed.
4827    ///
4828    /// `fetch_issues` reads a queue to work: it drops a closed issue and fails
4829    /// when nothing survives. Both are wrong for reading one issue back, where
4830    /// closed is an answer and the empty case cannot arise.
4831    pub fn read_issue(&self, number: i64) -> Result<Issue> {
4832        let text = self
4833            .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4834            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4835        serde_json::from_str(&text)
4836            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
4837    }
4838
4839    pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
4840        let mut issues = Vec::new();
4841        for number in numbers {
4842            let text = self
4843                .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4844                .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4845            let issue: Issue = serde_json::from_str(&text)
4846                .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4847            if issue.is_closed() {
4848                crate::log!("issue #{number} is closed, skipping");
4849                continue;
4850            }
4851            issues.push(issue);
4852        }
4853        if issues.is_empty() {
4854            bail!("no open issues to work on");
4855        }
4856        Ok(issues)
4857    }
4858
4859    /// Open items, lowest numbered first, from `min_number` upward.
4860    ///
4861    /// The floor exists because a long lived repository accumulates a tail of
4862    /// old issues nobody is going to get to, and taking the lowest numbered
4863    /// open items means walking straight into them.
4864    fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4865        #[derive(Deserialize)]
4866        struct Row {
4867            number: i64,
4868        }
4869        let text = self.gh(&[
4870            kind,
4871            "list",
4872            "--state",
4873            "open",
4874            "--limit",
4875            &FETCH_CEILING.to_string(),
4876            "--json",
4877            "number",
4878        ])?;
4879        let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4880        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
4881        numbers.sort_unstable();
4882
4883        let noun = if kind == "issue" { "issues" } else { "PRs" };
4884        let found = numbers.len();
4885        if min_number > 0 {
4886            numbers.retain(|n| *n >= min_number);
4887            let skipped = found - numbers.len();
4888            if skipped > 0 {
4889                crate::log!("{skipped} open {noun} below #{min_number} skipped");
4890            }
4891        }
4892        if found >= FETCH_CEILING {
4893            crate::log!(
4894                "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
4895                 considered."
4896            );
4897        }
4898        if numbers.len() > limit {
4899            crate::log!(
4900                "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
4901                 explicitly.",
4902                numbers.len()
4903            );
4904            numbers.truncate(limit);
4905        }
4906        Ok(numbers)
4907    }
4908
4909    /// Open issues, lowest numbered first. `gh issue list` excludes PRs.
4910    pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4911        self.open_numbers("issue", limit, min_number)
4912    }
4913
4914    pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4915        self.open_numbers("pr", limit, min_number)
4916    }
4917
4918    pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
4919        self.branch_prs(branch, "open").into_iter().next()
4920    }
4921
4922    /// The open pull request for a branch, preserving a failed lookup as an
4923    /// error when the caller is deciding whether a write already landed.
4924    pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
4925        let text = self.gh(&[
4926            "pr",
4927            "list",
4928            "--head",
4929            branch,
4930            "--base",
4931            base,
4932            "--state",
4933            "open",
4934            "--json",
4935            "number,url,title,baseRefName",
4936        ])?;
4937        pr_for_base(&text, branch, base)
4938    }
4939
4940    /// Every pull request opened from this branch, merged and closed ones
4941    /// included, because a commit is preserved by whichever one carries it and
4942    /// that is rarely the newest.
4943    fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
4944        self.branch_prs(branch, "all")
4945    }
4946
4947    fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
4948        let text = self.gh_try(&[
4949            "pr",
4950            "list",
4951            "--head",
4952            branch,
4953            "--state",
4954            state,
4955            "--json",
4956            "number,url,title",
4957        ]);
4958        serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
4959    }
4960
4961    /// Whether a number names an issue or a pull request.
4962    ///
4963    /// `gh issue view` happily returns a pull request when handed its number,
4964    /// so it cannot be used to tell them apart. The issues API carries both and
4965    /// marks a pull request with a `pull_request` key, which is definitive.
4966    pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
4967        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
4968        let text = self
4969            .gh(&[
4970                "api",
4971                &path,
4972                "--jq",
4973                "if .pull_request then \"pr\" else \"issue\" end",
4974            ])
4975            .map_err(|e| {
4976                spar_err!(
4977                    "no issue or pull request #{number} in this repository. {}",
4978                    e.last_line()
4979                )
4980            })?;
4981        match text.trim() {
4982            "pr" => Ok(ItemKind::Pr),
4983            "issue" => Ok(ItemKind::Issue),
4984            other => Err(spar_err!(
4985                "could not tell whether #{number} is an issue or a pull request (got {other:?})"
4986            )),
4987        }
4988    }
4989
4990    /// An open pull request that would close this issue, whoever opened it.
4991    ///
4992    /// spar's own branch naming is checked first because it is exact and cheap.
4993    /// Falling back to GitHub's own issue linkage is what lets spar pick up a
4994    /// pull request a person started on a branch named anything at all.
4995    ///
4996    /// An error rather than `None` when the question could not be asked. The
4997    /// two answers are not interchangeable: "no pull request exists" is the one
4998    /// that makes spar implement over the top of somebody's work and open a
4999    /// second pull request for the same issue.
5000    pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
5001        let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
5002        if let Some(pr) = mine {
5003            return Ok(Some(pr));
5004        }
5005        let listed = match self.gh(&[
5006            "pr",
5007            "list",
5008            "--state",
5009            "open",
5010            "--limit",
5011            &FETCH_CEILING.to_string(),
5012            "--json",
5013            "number,url,title,baseRefName,closingIssuesReferences",
5014        ]) {
5015            Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
5016                spar_err!("could not read the pull request list while looking for #{issue}")
5017            })?,
5018            Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
5019        };
5020        if listed.len() >= FETCH_CEILING {
5021            logwarn!(
5022                "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a                  pull request past it cannot be seen from here."
5023            );
5024        }
5025        let default = self.default_branch(base);
5026        if !base.eq_ignore_ascii_case(&default) {
5027            // GitHub populates closingIssuesReferences only for pull requests
5028            // targeting the default branch, so on any other base the linkage is
5029            // silence rather than an answer.
5030            logwarn!(
5031                "#{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."
5032            );
5033        }
5034        Ok(pick_linked_pr(&listed, issue))
5035    }
5036
5037    /// spar's own branch, with a failed lookup kept as a failure.
5038    fn pr_for_branch_failing_closed(
5039        &self,
5040        branch: &str,
5041        base: &str,
5042        issue: i64,
5043    ) -> Result<Option<PrRef>> {
5044        match self.try_pr_for_branch(branch, base) {
5045            Ok(found) => Ok(found),
5046            Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
5047        }
5048    }
5049
5050    /// What to do when GitHub would not say whether a pull request exists.
5051    ///
5052    /// An error, because "no pull request exists" is the answer that makes spar
5053    /// implement from scratch and open a second pull request over somebody's
5054    /// work. The one exception is a repository gh cannot reach at all: there is
5055    /// no pull request to collide with when there is no GitHub repository in
5056    /// play, and every later write would fail anyway. That case is a warning and
5057    /// a `None`, so a run against a plain git remote behaves as it always did.
5058    fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
5059        if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
5060            return Err(spar_err!(
5061                "could not check whether a pull request already exists for #{issue}: {}. \
5062                 Refusing to implement over the top of work that may already be open.",
5063                e.last_line()
5064            ));
5065        }
5066        logwarn!(
5067            "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
5068             whether a pull request already exists for it.",
5069            e.last_line()
5070        );
5071        Ok(())
5072    }
5073
5074    pub fn pr_view(&self, number: i64) -> Result<PrView> {
5075        let text = self.gh(&[
5076            "pr",
5077            "view",
5078            &number.to_string(),
5079            "--json",
5080            "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,\
5081             isCrossRepository,headRepositoryOwner,headRepository",
5082        ])?;
5083        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
5084    }
5085
5086    fn try_pr_state(&self, number: i64) -> Result<String> {
5087        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
5088        serde_json::from_str::<Value>(text.trim())
5089            .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
5090            .get("state")
5091            .and_then(Value::as_str)
5092            .map(str::to_string)
5093            .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
5094    }
5095
5096    pub fn pr_state(&self, number: i64) -> String {
5097        self.try_pr_state(number).unwrap_or_default()
5098    }
5099
5100    /// The commit currently exposed as a pull request's head.
5101    pub fn pr_head_oid(&self, number: i64) -> Result<String> {
5102        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
5103        let oid = serde_json::from_str::<Value>(&text)
5104            .ok()
5105            .and_then(|value| {
5106                value
5107                    .get("headRefOid")
5108                    .and_then(Value::as_str)
5109                    .map(str::trim)
5110                    .filter(|oid| !oid.is_empty())
5111                    .map(str::to_string)
5112            })
5113            .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
5114        Ok(oid)
5115    }
5116
5117    /// Where a pull request's head stands now: what GitHub's pull request
5118    /// names, and, for a branch in this repository, where `origin` has the
5119    /// branch. The two differ until GitHub catches up with a push.
5120    pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
5121        let pull = self.pr_head_oid(pr.number)?;
5122        let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
5123            None
5124        } else {
5125            let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
5126            let listed = self
5127                .git(&["ls-remote", "--heads", "origin", &remote_ref])
5128                .map_err(|e| {
5129                    spar_err!(
5130                        "could not read where {} is on origin: {}",
5131                        pr.head_ref_name.trim(),
5132                        e.last_line()
5133                    )
5134                })?;
5135            remote_head_oid(&listed, &remote_ref)?
5136        };
5137        Ok(LiveHead { pull, branch })
5138    }
5139
5140    /// Open a pull request from `branch`, published at `target`.
5141    ///
5142    /// The lookup that confirms it landed goes by branch name alone, which
5143    /// `gh pr list --head` matches on a fork's pull request too.
5144    pub fn create_pr(
5145        &self,
5146        cwd: &Path,
5147        branch: &str,
5148        target: &PushTarget,
5149        base: &str,
5150        title: &str,
5151        body: &str,
5152    ) -> Result<PrRef> {
5153        let title = self.record_failed_write(self.clean_title(title))?;
5154        let body = self.record_failed_write(self.clean(body))?;
5155        let head = target.head(branch);
5156        let mut argv = vec![
5157            "pr", "create", "--base", base, "--head", &head, "--title", &title, "--body", &body,
5158        ];
5159        if self.drafts != Drafts::Never {
5160            argv.push("--draft");
5161        }
5162        let created = self.gh_at(Some(cwd), &argv);
5163        let found = self.try_pr_for_branch(branch, base);
5164        self.record_write(reconcile_pr_creation(branch, created, found))
5165    }
5166
5167    pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
5168        let body = signed(&self.record_failed_write(self.clean(body))?);
5169        let comments = self.record_failed_write(self.try_issue_comments(number))?;
5170        if has_exact_comment(&comments, &body) {
5171            return Ok(());
5172        }
5173        let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
5174        let result = match posted {
5175            Ok(_) => Ok(()),
5176            Err(post_error) => {
5177                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5178            }
5179        };
5180        self.record_write(result)
5181    }
5182
5183    /// Whether the viewer wrote this pull request. GitHub refuses to let an
5184    /// author approve or request changes on their own, so a verdict on one can
5185    /// only ever be a comment.
5186    fn pr_is_own(&self, number: i64) -> Result<bool> {
5187        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
5188        let author = serde_json::from_str::<Value>(&text)
5189            .ok()
5190            .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
5191            .unwrap_or_default();
5192        let viewer = self.viewer_login()?.to_string();
5193        Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
5194    }
5195
5196    /// Submit a formal review, or a comment carrying the verdict's words when
5197    /// GitHub will not take the verdict: the viewer's own pull request, or no
5198    /// verdict at all.
5199    ///
5200    /// Mirrors `comment_pr` step for step: the style gate, the signature, a
5201    /// duplicate check against the viewer's own reviews, the write, and a read
5202    /// back on failure so an unverifiable write is reported as one.
5203    ///
5204    /// `commit` is the commit the reviewers read. GitHub attaches a review
5205    /// that names no commit to whatever the head is when it arrives, so a push
5206    /// made while a review ran would otherwise receive an approval nobody
5207    /// gave it. `gh pr review` cannot name one, hence the REST call.
5208    ///
5209    /// `comments` go on their lines in the same review, so they arrive with
5210    /// it or not at all. `body` goes with them, and may leave to them what
5211    /// they say. `full` says everything, and is what goes up when they do not.
5212    /// GitHub refuses the whole review over one line its diff does not show,
5213    /// so when it refused this one and the read back finds nothing landed, the
5214    /// review goes up again without them, with `full`. Any other failure is
5215    /// reconciled as a review without them would be, never retried: a gateway
5216    /// error can arrive while GitHub is still saving a review that will land.
5217    ///
5218    /// Either body standing on `commit` already is this review, so a rerun
5219    /// after the fallback landed does not post it again.
5220    pub fn submit_review(
5221        &self,
5222        number: i64,
5223        verdict: Option<crate::model::ReviewVerdict>,
5224        body: &str,
5225        full: &str,
5226        commit: &str,
5227        comments: &[InlineComment],
5228    ) -> Result<()> {
5229        let own = match verdict {
5230            Some(_) => self.record_failed_write(self.pr_is_own(number))?,
5231            None => false,
5232        };
5233        let prepare = |body: &str| {
5234            let text = match (verdict, own) {
5235                (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
5236                _ => body.to_string(),
5237            };
5238            self.record_failed_write(self.clean(&text))
5239                .map(|b| signed(&b))
5240        };
5241        let body = prepare(body)?;
5242        let full = prepare(full)?;
5243        let mut inline = Vec::with_capacity(comments.len());
5244        for c in comments {
5245            inline.push(InlineComment {
5246                body: signed(&self.record_failed_write(self.clean(&c.body))?),
5247                ..c.clone()
5248            });
5249        }
5250        let either = [body.as_str(), full.as_str()];
5251        let viewer = self.record_failed_write(self.viewer_login())?.to_string();
5252        let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
5253        if has_viewer_review(&reviews, &viewer, &either, commit) {
5254            return Ok(());
5255        }
5256        let event = match (verdict, own) {
5257            (Some(v), false) => v.api_event(),
5258            _ => "COMMENT",
5259        };
5260        let result = if inline.is_empty() {
5261            self.post_review(number, &full, &viewer, commit, event)
5262        } else {
5263            let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5264            let payload = review_payload(commit, event, &body, &inline).to_string();
5265            match self.gh_stdin(
5266                &["api", "-X", "POST", &path, "--input", "-", "--silent"],
5267                &payload,
5268            ) {
5269                Ok(_) => Ok(()),
5270                Err(post_error) if !refused(&post_error) => reconcile_review_post(
5271                    number,
5272                    &[&body],
5273                    &viewer,
5274                    commit,
5275                    post_error,
5276                    self.try_pr_reviews(number),
5277                ),
5278                Err(post_error) => match self.try_pr_reviews(number) {
5279                    Ok(reviews) if !has_viewer_review(&reviews, &viewer, &either, commit) => {
5280                        logwarn!(
5281                            "the review of #{number} with its line comments did not go up ({}), \
5282                             so it is going up with every finding in its body only",
5283                            post_error.last_line()
5284                        );
5285                        self.post_review(number, &full, &viewer, commit, event)
5286                    }
5287                    reviews => {
5288                        reconcile_review_post(number, &either, &viewer, commit, post_error, reviews)
5289                    }
5290                },
5291            }
5292        };
5293        self.record_write(result)
5294    }
5295
5296    /// A review with its body alone, read back if the post reports a failure.
5297    fn post_review(
5298        &self,
5299        number: i64,
5300        body: &str,
5301        viewer: &str,
5302        commit: &str,
5303        event: &str,
5304    ) -> Result<()> {
5305        let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5306        let posted = self.gh(&[
5307            "api",
5308            "-X",
5309            "POST",
5310            &path,
5311            "-f",
5312            &format!("commit_id={commit}"),
5313            "-f",
5314            &format!("event={event}"),
5315            "-f",
5316            &format!("body={body}"),
5317            "--silent",
5318        ]);
5319        match posted {
5320            Ok(_) => Ok(()),
5321            Err(post_error) => reconcile_review_post(
5322                number,
5323                &[body],
5324                viewer,
5325                commit,
5326                post_error,
5327                self.try_pr_reviews(number),
5328            ),
5329        }
5330    }
5331
5332    pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
5333        let body = signed(&self.record_failed_write(self.clean(body))?);
5334        let comments = self.record_failed_write(self.try_issue_comments(number))?;
5335        if has_exact_comment(&comments, &body) {
5336            return Ok(());
5337        }
5338        let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
5339        let result = match posted {
5340            Ok(_) => Ok(()),
5341            Err(post_error) => {
5342                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5343            }
5344        };
5345        self.record_write(result)
5346    }
5347
5348    /// Comment, then close as not planned.
5349    ///
5350    /// Only ever called when both agents independently declined the issue: one
5351    /// agent's opinion is not enough to close somebody's report.
5352    pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
5353        self.comment_issue(number, body)?;
5354        let n = number.to_string();
5355        let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
5356            Ok(_) => Ok(()),
5357            // Older gh builds do not take --reason.
5358            Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
5359                spar_err!(
5360                    "commented on #{number} but could not close it: {}",
5361                    e.last_line()
5362                )
5363            }),
5364        };
5365        self.record_write(closed)
5366    }
5367
5368    /// Replace an issue body, refusing unless it is still byte for byte what
5369    /// the caller read and validating only the fragment spar inserted.
5370    ///
5371    /// The only place spar rewrites text somebody else wrote, so the check is
5372    /// the whole point: an edit computed from a body that has since moved would
5373    /// silently delete whatever moved it. The caller decides whether another
5374    /// attempt is safe for its workflow.
5375    ///
5376    /// Deliberately not through `clean_issue_body`. The body is mostly a
5377    /// person's own prose, and the scrub would rewrite their punctuation while
5378    /// the length budget could truncate the end of a long report. `inserted` is
5379    /// the only text here spar is answerable for, so it still passes through the
5380    /// style gate. The full body travels over stdin because a tracker can be far
5381    /// too long for one argument.
5382    pub fn edit_issue_body(
5383        &self,
5384        number: i64,
5385        expected: &str,
5386        body: &str,
5387        inserted: &str,
5388    ) -> Result<()> {
5389        let cleaned = self.record_failed_write(self.clean(inserted))?;
5390        if cleaned.trim() != inserted.trim() {
5391            return self.record_failed_write(Err(spar_err!(
5392                "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
5393            )));
5394        }
5395        let current = self.record_failed_write(self.issue_body(number))?;
5396        if current != expected {
5397            return self.record_failed_write(Err(spar_err!(
5398                "the body of #{number} changed since it was read, so it was left alone rather \
5399                 than written over."
5400            )));
5401        }
5402        let edited = self.gh_stdin(
5403            &["issue", "edit", &number.to_string(), "--body-file", "-"],
5404            body,
5405        );
5406        let result = match edited {
5407            Ok(_) => Ok(()),
5408            Err(edit_error) => {
5409                reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
5410            }
5411        };
5412        self.record_write(result)
5413    }
5414
5415    /// One issue's body, exactly as GitHub holds it.
5416    pub fn issue_body(&self, number: i64) -> Result<String> {
5417        #[derive(Deserialize)]
5418        struct Row {
5419            #[serde(default)]
5420            body: Option<String>,
5421        }
5422        let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
5423        let row: Row = serde_json::from_str(text.trim())
5424            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
5425        Ok(row.body.unwrap_or_default())
5426    }
5427
5428    /// Every open issue with its title and body, in one call.
5429    ///
5430    /// For a screen that has to say something about each of twenty items before
5431    /// anything expensive happens. One call rather than one per issue.
5432    pub fn open_issue_rows(&self) -> Vec<Issue> {
5433        let text = self.gh_try(&[
5434            "issue",
5435            "list",
5436            "--state",
5437            "open",
5438            "--limit",
5439            &FETCH_CEILING.to_string(),
5440            "--json",
5441            "number,title,body,labels,state,stateReason,url",
5442        ]);
5443        serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
5444    }
5445
5446    /// Every open pull request with its size, in one call.
5447    pub fn open_pr_rows(&self) -> Vec<PrRow> {
5448        let text = self.gh_try(&[
5449            "pr",
5450            "list",
5451            "--state",
5452            "open",
5453            "--limit",
5454            &FETCH_CEILING.to_string(),
5455            "--json",
5456            "number,title,changedFiles,additions,deletions",
5457        ]);
5458        serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
5459    }
5460
5461    pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
5462        self.create_issue_apart_from(title, body, None)
5463    }
5464
5465    pub fn create_issue_apart_from(
5466        &self,
5467        title: &str,
5468        body: &str,
5469        apart_from: Option<i64>,
5470    ) -> Result<String> {
5471        let title = self.record_failed_write(self.clean_title(title))?;
5472        let body = self.record_failed_write(self.clean_issue_body(body))?;
5473        let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
5474        let result = match created {
5475            Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
5476            created => {
5477                let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
5478                reconcile_issue_creation(&title, created, found)
5479            }
5480        };
5481        self.record_write(result)
5482    }
5483}
5484
5485/// An issue that already covers what spar was about to file.
5486#[derive(Debug, Clone)]
5487pub struct ExistingIssue {
5488    pub number: i64,
5489    pub url: String,
5490    pub title: String,
5491    pub body: String,
5492    pub open: bool,
5493}
5494
5495impl Repo {
5496    pub(crate) fn try_exact_issue_apart_from(
5497        &self,
5498        title: &str,
5499        body: &str,
5500        apart_from: Option<i64>,
5501    ) -> Result<Option<ExistingIssue>> {
5502        #[derive(Deserialize)]
5503        #[serde(rename_all = "camelCase")]
5504        struct Row {
5505            number: i64,
5506            #[serde(default)]
5507            title: String,
5508            #[serde(default)]
5509            url: String,
5510            #[serde(default)]
5511            body: Option<String>,
5512            #[serde(default)]
5513            state: String,
5514        }
5515
5516        let text = self.gh(&[
5517            "issue",
5518            "list",
5519            "--state",
5520            "all",
5521            "--limit",
5522            "100",
5523            "--json",
5524            "number,title,url,body,state,stateReason",
5525        ])?;
5526        let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5527            .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
5528        Ok(rows
5529            .into_iter()
5530            .filter(|row| Some(row.number) != apart_from)
5531            .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
5532            .map(|row| ExistingIssue {
5533                number: row.number,
5534                url: row.url,
5535                title: row.title,
5536                body: row.body.unwrap_or_default(),
5537                open: row.state.eq_ignore_ascii_case("open"),
5538            }))
5539    }
5540
5541    /// An issue that already describes this defect, however it was worded.
5542    ///
5543    /// Exact title matching let duplicates through: two agents, or two runs a
5544    /// week apart, never word one defect identically. A real run filed two
5545    /// duplicates that way, and each had to be closed by hand afterwards.
5546    /// Titles alone are too thin to match on, so this compares titles and
5547    /// bodies together.
5548    pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
5549        self.find_similar_issue_apart_from(title, body, None)
5550    }
5551
5552    /// The same search, with one issue that cannot be its own duplicate.
5553    ///
5554    /// A tracker's body quotes every item in its checklist, so searching for an
5555    /// item's words matches the tracker before it matches anything else. That
5556    /// would link an item to the issue it is written in.
5557    /// The search a tracker runs, which wants closed issues too: a checklist
5558    /// item matching a closed issue is a done item that wants a link.
5559    pub fn find_similar_issue_apart_from(
5560        &self,
5561        title: &str,
5562        body: &str,
5563        apart_from: Option<i64>,
5564    ) -> Option<ExistingIssue> {
5565        self.try_similar_issue(title, body, apart_from, true)
5566            .ok()
5567            .flatten()
5568    }
5569
5570    /// The search a filing runs, over open issues only.
5571    pub fn try_find_similar_issue_apart_from(
5572        &self,
5573        title: &str,
5574        body: &str,
5575        apart_from: Option<i64>,
5576    ) -> Result<Option<ExistingIssue>> {
5577        self.try_similar_issue(title, body, apart_from, false)
5578    }
5579
5580    /// The same search, preserving lookup failure for a caller about to write.
5581    fn try_similar_issue(
5582        &self,
5583        title: &str,
5584        body: &str,
5585        apart_from: Option<i64>,
5586        include_closed: bool,
5587    ) -> Result<Option<ExistingIssue>> {
5588        #[derive(Deserialize)]
5589        #[serde(rename_all = "camelCase")]
5590        struct Row {
5591            number: i64,
5592            #[serde(default)]
5593            title: String,
5594            #[serde(default)]
5595            url: String,
5596            #[serde(default)]
5597            body: String,
5598            #[serde(default)]
5599            state: String,
5600        }
5601        if title.trim().is_empty() {
5602            return Ok(None);
5603        }
5604        // Search on the title's own words: GitHub's index is the cheap way to
5605        // narrow the field before comparing properly.
5606        let query: String = title
5607            .chars()
5608            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5609            .take(120)
5610            .collect();
5611        // Open issues only when something is about to be filed. A fuzzy match
5612        // against a closed one is the case worth keeping rather than dropping:
5613        // a defect that was fixed, closed, and has come back is exactly the
5614        // finding a maintainer wants, and "#N covers it and is closed" wrote it
5615        // nowhere. An exact match is still checked against every state, since
5616        // that is a regression of one specific issue rather than a resemblance
5617        // to one, and a tracker still searches closed issues because a
5618        // checklist item matching one is a done item that wants a link.
5619        let state = if include_closed { "all" } else { "open" };
5620        let text = self.gh(&[
5621            "issue",
5622            "list",
5623            "--state",
5624            state,
5625            "--limit",
5626            "100",
5627            "--search",
5628            query.trim(),
5629            "--json",
5630            "number,title,url,body,state,stateReason",
5631        ])?;
5632        let rows: Vec<Row> = serde_json::from_str(text.trim())
5633            .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
5634
5635        Ok(rows
5636            .into_iter()
5637            .filter(|row| Some(row.number) != apart_from)
5638            .find(|row| {
5639                row.title.trim().eq_ignore_ascii_case(title.trim())
5640                    || textsim::covers(title, body, &row.title, &row.body)
5641            })
5642            .map(|row| ExistingIssue {
5643                number: row.number,
5644                url: row.url,
5645                title: row.title,
5646                open: row.state.eq_ignore_ascii_case("open"),
5647                body: row.body,
5648            }))
5649    }
5650
5651    /// Avoid filing a duplicate when a follow-up already exists.
5652    pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
5653        #[derive(Deserialize)]
5654        struct Row {
5655            title: String,
5656            url: String,
5657        }
5658        let needle = title.trim().to_lowercase();
5659        if needle.is_empty() {
5660            return None;
5661        }
5662        // Quotes and newlines would be read as search syntax rather than text.
5663        let query: String = title
5664            .chars()
5665            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5666            .take(120)
5667            .collect();
5668        let text = self.gh_try(&[
5669            "issue",
5670            "list",
5671            "--state",
5672            "all",
5673            "--limit",
5674            "100",
5675            "--search",
5676            query.trim(),
5677            "--json",
5678            "number,title,url",
5679        ]);
5680        serde_json::from_str::<Vec<Row>>(text.trim())
5681            .ok()?
5682            .into_iter()
5683            .find(|row| row.title.trim().to_lowercase() == needle)
5684            .map(|row| row.url)
5685    }
5686
5687    /// Squash merge, tolerating cleanup failures after a successful merge.
5688    ///
5689    /// Take a pull request out of draft, once the review has converged.
5690    ///
5691    /// Best effort for the remaining workflow. A failure does not discard the
5692    /// review or stop later independent work, but the final write summary
5693    /// reports it and the command returns non-zero.
5694    pub fn mark_ready(&self, number: i64) -> bool {
5695        match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
5696            Ok(_) => true,
5697            Err(e) => {
5698                logdim!(
5699                    "PR #{number} is approved but could not be taken out of draft: {}",
5700                    e.last_line()
5701                );
5702                false
5703            }
5704        }
5705    }
5706
5707    /// The worktree that has `branch` checked out, if one does.
5708    fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
5709        let listed = self
5710            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
5711            .ok()?;
5712        let wanted = format!("branch refs/heads/{branch}");
5713        let mut current: Option<PathBuf> = None;
5714        for line in listed.lines() {
5715            if let Some(path) = line.strip_prefix("worktree ") {
5716                current = Some(PathBuf::from(path));
5717            } else if line == wanted {
5718                return current;
5719            }
5720        }
5721        None
5722    }
5723
5724    /// Finish the local cleanup a merge could not.
5725    ///
5726    /// `gh pr merge --delete-branch` gives up on the local branch whenever any
5727    /// worktree has it checked out, which is the ordinary state rather than an
5728    /// odd one: `spar run` opens the pull request from
5729    /// `.spar-worktrees/issue-N` and leaves the checkout there, and the merge
5730    /// usually happens in a later `spar resume` working somewhere else. The
5731    /// next run's sweep tidied it, so nothing was ever lost, but the run that
5732    /// did the merge reported a failure about a branch nobody will touch again
5733    /// and left a checkout for the next run to explain.
5734    ///
5735    /// Held to the same rules the sweep uses, because they are the rules that
5736    /// decide whether anything would be lost: a branch spar recorded creating,
5737    /// a worktree of spar's own with no recoverable work in it, and a tip some
5738    /// surviving ref preserves. Anything else is left exactly where it is.
5739    fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
5740        let branch = self.pr_view(number).ok()?.head_ref_name;
5741        if branch.is_empty() || !self.known_branches().contains_key(&branch) {
5742            return None;
5743        }
5744        let mut released = None;
5745        if let Some(path) = self.worktree_holding(&branch) {
5746            if !path.starts_with(self.root.join(WORKTREE_DIR)) {
5747                return None;
5748            }
5749            if !matches!(
5750                self.has_recoverable_work(&path, Disposable::BuildOutput),
5751                Ok(false)
5752            ) {
5753                return None;
5754            }
5755            match self.remove_worktree_at(&path) {
5756                Ok(true) => released = Some(path),
5757                _ => return None,
5758            }
5759        }
5760        match self.delete_branch_if_safe(&branch) {
5761            Ok(true) => {
5762                self.forget_branch(&branch);
5763                Some(match released {
5764                    Some(path) => format!(
5765                        " The worktree at {} was released and branch {branch} deleted.",
5766                        path.display()
5767                    ),
5768                    None => format!(" Branch {branch} was deleted."),
5769                })
5770            }
5771            _ => released.map(|path| {
5772                format!(
5773                    " The worktree at {} was released, but branch {branch} was kept.",
5774                    path.display()
5775                )
5776            }),
5777        }
5778    }
5779
5780    /// `gh pr merge --delete-branch` exits non-zero when it cannot delete the
5781    /// local branch, which happens *after* the merge has already landed.
5782    /// Treating that as a failure reports work as lost when it is not.
5783    pub fn merge_pr(&self, number: i64) -> Result<()> {
5784        let n = number.to_string();
5785        let merged = match self.gh(&merge_pr_args(&n, None, true)) {
5786            Ok(_) => Ok(()),
5787            Err(e) => {
5788                if self.pr_state(number) == "MERGED" {
5789                    let finished = self
5790                        .finish_merged_branch_cleanup(number)
5791                        .unwrap_or_default();
5792                    logdim!(
5793                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5794                        e.last_line()
5795                    );
5796                    Ok(())
5797                } else {
5798                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5799                }
5800            }
5801        };
5802        self.record_write(merged)
5803    }
5804
5805    /// Squash merge only if the pull request still exposes the reviewed head.
5806    pub fn merge_pr_at_head(
5807        &self,
5808        number: i64,
5809        expected_head: &str,
5810        delete_branch: bool,
5811    ) -> Result<()> {
5812        let n = number.to_string();
5813        let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
5814            Ok(_) => Ok(()),
5815            Err(e) => {
5816                if self.pr_state(number) == "MERGED" {
5817                    let finished = self
5818                        .finish_merged_branch_cleanup(number)
5819                        .unwrap_or_default();
5820                    logdim!(
5821                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5822                        e.last_line()
5823                    );
5824                    Ok(())
5825                } else {
5826                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5827                }
5828            }
5829        };
5830        self.record_write(merged)
5831    }
5832
5833    // -- follow-ups -------------------------------------------------------
5834
5835    /// The queue of follow-ups recorded locally rather than filed, which
5836    /// `spar followup` works.
5837    pub fn followups_path(&self) -> PathBuf {
5838        self.root.join(STATE_DIR).join("followups.md")
5839    }
5840
5841    /// Where `spar brainstorm` writes a session unless told otherwise.
5842    ///
5843    /// Under the state directory, so it is kept out of the repository's own
5844    /// `git status`; `--out` puts one somewhere a person would commit.
5845    pub fn brainstorms_dir(&self) -> PathBuf {
5846        self.root.join(STATE_DIR).join("brainstorms")
5847    }
5848
5849    /// What `spar followup` already dealt with, kept beside the queue.
5850    ///
5851    /// Two jobs. It is what stops `append_local_followup` re-recording a
5852    /// follow-up whose entry has since left the queue, which would otherwise
5853    /// turn the file into a ring buffer of things already filed. And it keeps
5854    /// the text of an entry a screening pass ruled stale, so a wrong verdict
5855    /// costs a re-read rather than the only copy of a real defect.
5856    pub fn worked_followups_path(&self) -> PathBuf {
5857        self.root.join(STATE_DIR).join("followups.done.md")
5858    }
5859
5860    /// What `spar checkin` has already answered on one pull request or issue.
5861    /// Where what a run spent is kept.
5862    pub fn spend_path(&self) -> PathBuf {
5863        self.root.join(STATE_DIR).join("state").join("spend.json")
5864    }
5865
5866    /// Append this run's calls to the record.
5867    ///
5868    /// Appended rather than replaced, so a resumed pull request adds to the
5869    /// count of what it has cost so far rather than starting it again, and so
5870    /// anybody charting this has a file to chart.
5871    pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
5872        if calls.is_empty() {
5873            return Ok(());
5874        }
5875        let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
5876            .ok()
5877            .and_then(|text| serde_json::from_str(&text).ok())
5878            .unwrap_or_default();
5879        all.extend(calls.iter().cloned());
5880        if let Some(parent) = self.spend_path().parent() {
5881            let _ = std::fs::create_dir_all(parent);
5882        }
5883        write_json_atomic(&self.spend_path(), &all)
5884    }
5885
5886    /// Who wrote the first draft of each issue, by number.
5887    ///
5888    /// Custody alternates within a pull request and the record of it lives in
5889    /// that pull request's state. This is the other question, which nothing
5890    /// answered: over a queue, which model authored what.
5891    pub fn implementors_path(&self) -> PathBuf {
5892        self.root
5893            .join(STATE_DIR)
5894            .join("state")
5895            .join("implementors.json")
5896    }
5897
5898    pub fn read_implementors(&self) -> BTreeMap<i64, String> {
5899        std::fs::read_to_string(self.implementors_path())
5900            .ok()
5901            .and_then(|text| serde_json::from_str(&text).ok())
5902            .unwrap_or_default()
5903    }
5904
5905    /// Best effort: this is a record for a person, not something the loop reads
5906    /// back, so a failure to write it is not a reason to stop.
5907    pub fn remember_implementor(&self, issue: i64, agent: &str) {
5908        let mut known = self.read_implementors();
5909        known.insert(issue, agent.to_string());
5910        if let Some(parent) = self.implementors_path().parent() {
5911            let _ = std::fs::create_dir_all(parent);
5912        }
5913        if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
5914            logdim!("could not record who implemented #{issue}: {e}");
5915        }
5916    }
5917
5918    /// Where the issues triage disagreed about are remembered.
5919    ///
5920    /// One file rather than one per issue: it is read once per run, before any
5921    /// triage call, to decide what not to ask about again.
5922    pub fn contested_path(&self) -> PathBuf {
5923        self.root
5924            .join(STATE_DIR)
5925            .join("state")
5926            .join("contested.json")
5927    }
5928
5929    /// The issues a person has been asked to decide, by number.
5930    pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
5931        std::fs::read_to_string(self.contested_path())
5932            .ok()
5933            .and_then(|text| serde_json::from_str(&text).ok())
5934            .unwrap_or_default()
5935    }
5936
5937    /// Remember an issue triage could not agree on.
5938    ///
5939    /// Best effort: a run that cannot write this asks again next time, which is
5940    /// what it did before this existed.
5941    pub fn remember_contested(&self, items: &[ContestedItem]) {
5942        if items.is_empty() {
5943            return;
5944        }
5945        let mut known = self.read_contested();
5946        for item in items {
5947            known.insert(
5948                item.issue,
5949                Contested {
5950                    title: item.title.clone(),
5951                    positions: item.positions.clone(),
5952                    reasons: item.reasons.clone(),
5953                },
5954            );
5955        }
5956        if let Some(parent) = self.contested_path().parent() {
5957            let _ = std::fs::create_dir_all(parent);
5958        }
5959        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5960            logdim!("could not record the contested issues: {e}");
5961        }
5962    }
5963
5964    /// Ask about them again, because a person has acted on them.
5965    pub fn forget_contested(&self, numbers: &[i64]) {
5966        let mut known = self.read_contested();
5967        let before = known.len();
5968        known.retain(|number, _| !numbers.contains(number));
5969        if known.len() == before {
5970            return;
5971        }
5972        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5973            logdim!("could not update the contested issues: {e}");
5974        }
5975    }
5976
5977    pub fn checkin_state_path(&self, number: i64) -> PathBuf {
5978        self.root
5979            .join(STATE_DIR)
5980            .join("state")
5981            .join(format!("checkin-{number}.json"))
5982    }
5983
5984    /// Where `spar answer` records which mentions on an issue it judged.
5985    pub fn answer_state_path(&self, number: i64) -> PathBuf {
5986        self.root
5987            .join(STATE_DIR)
5988            .join("state")
5989            .join(format!("answer-{number}.json"))
5990    }
5991
5992    /// Where `spar answer` keeps what it left for a person to answer. Notes,
5993    /// never posted.
5994    pub fn answer_notes_path(&self, number: i64) -> PathBuf {
5995        self.root
5996            .join(STATE_DIR)
5997            .join("answers")
5998            .join(format!("issue-{number}.md"))
5999    }
6000
6001    /// Where `spar review` records what it reviewed, and a second look reads it.
6002    pub fn review_record_path(&self, number: i64) -> PathBuf {
6003        self.root
6004            .join(STATE_DIR)
6005            .join("state")
6006            .join(format!("review-{number}.json"))
6007    }
6008
6009    /// None when there is no record, or when it cannot be read. A record spar
6010    /// cannot read is one it reviews afresh over, and the log says so.
6011    pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
6012        let path = self.review_record_path(number);
6013        let text = std::fs::read_to_string(&path).ok()?;
6014        match serde_json::from_str(&text) {
6015            Ok(record) => Some(record),
6016            Err(e) => {
6017                logdim!("could not read {}: {e}", path.display());
6018                None
6019            }
6020        }
6021    }
6022
6023    pub fn write_review_record(
6024        &self,
6025        number: i64,
6026        record: &crate::model::ReviewRecord,
6027    ) -> Result<()> {
6028        write_json_atomic(&self.review_record_path(number), record)
6029    }
6030
6031    /// Append a follow-up to a local note instead of the tracker.
6032    ///
6033    /// Deduplicated on the title, matching the issue path. The body arrives
6034    /// with its provenance already stamped by the caller, so nothing is added
6035    /// here.
6036    ///
6037    /// A write that did not happen is reported as such rather than as a
6038    /// duplicate: the caller settles the point on the strength of this answer,
6039    /// and settling it on a failed write is how a real defect is lost.
6040    ///
6041    /// Both files are checked, because `spar followup` removes an entry from
6042    /// the queue once it has filed it. Checking only the queue would let the
6043    /// next run that rediscovers the same defect append it again, on top of the
6044    /// issue that now exists for it.
6045    pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
6046        let path = self.followups_path();
6047        let heading = format!("## {}", title.trim());
6048        for seen in [&path, &self.worked_followups_path()] {
6049            if let Ok(existing) = std::fs::read_to_string(seen) {
6050                if has_heading(&existing, title) {
6051                    logdim!("follow-up already noted: {title}");
6052                    return Followup::Covered(format!("note: {}", title.trim()));
6053                }
6054            }
6055        }
6056        if let Some(parent) = path.parent() {
6057            let _ = std::fs::create_dir_all(parent);
6058        }
6059        use std::io::Write;
6060        // The caller already stamped the provenance into the body. Adding
6061        // "From #N." here as well printed it twice, in two different wordings.
6062        //
6063        // The marker above the heading is what makes the entry boundary
6064        // unambiguous to the parser, since the body's own sections are written
6065        // at the same heading level as the title.
6066        let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
6067        match std::fs::OpenOptions::new()
6068            .create(true)
6069            .append(true)
6070            .open(&path)
6071        {
6072            Ok(mut file) => match file.write_all(entry.as_bytes()) {
6073                Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
6074                Err(e) => {
6075                    logdim!("could not write {}: {e}", path.display());
6076                    Followup::Failed
6077                }
6078            },
6079            Err(e) => {
6080                logdim!("could not write {}: {e}", path.display());
6081                Followup::Failed
6082            }
6083        }
6084    }
6085
6086    /// Record what `spar followup` did with an entry, and why.
6087    ///
6088    /// Best effort: an archive that could not be written is not a reason to
6089    /// stop, since the entry has already been filed or ruled on.
6090    pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
6091        let path = self.worked_followups_path();
6092        if let Some(parent) = path.parent() {
6093            let _ = std::fs::create_dir_all(parent);
6094        }
6095        use std::io::Write;
6096        let entry = format!(
6097            "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
6098            title.trim(),
6099            body.trim()
6100        );
6101        if let Ok(mut file) = std::fs::OpenOptions::new()
6102            .create(true)
6103            .append(true)
6104            .open(&path)
6105        {
6106            let _ = file.write_all(entry.as_bytes());
6107        }
6108    }
6109
6110    // -- resumable state --------------------------------------------------
6111    //
6112    // Custody cannot be read from GitHub authorship: every agent commits and
6113    // comments as the same git identity, so `author` is always the human who
6114    // ran spar. State is kept on disk by default and can additionally travel in
6115    // a PR comment, which is what lets a run be resumed from another machine.
6116
6117    /// Where a comment spar produced but did not post is kept.
6118    pub fn pending_comment_path(&self, number: i64) -> PathBuf {
6119        self.root
6120            .join(STATE_DIR)
6121            .join("reviews")
6122            .join(format!("pr-{number}.md"))
6123    }
6124
6125    /// Keep a comment spar decided not to post.
6126    ///
6127    /// A dry run that prints and forgets means agreeing with what you read
6128    /// costs a second full review. Saving it makes the whole point of reading
6129    /// it first: look, edit if you like, then post what you already paid for.
6130    pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
6131        let path = self.pending_comment_path(number);
6132        if let Some(parent) = path.parent() {
6133            std::fs::create_dir_all(parent)
6134                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6135        }
6136        std::fs::write(&path, text)
6137            .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6138        Ok(path)
6139    }
6140
6141    pub fn read_pending_comment(&self, number: i64) -> Option<String> {
6142        std::fs::read_to_string(self.pending_comment_path(number)).ok()
6143    }
6144
6145    pub fn state_path(&self, number: i64) -> PathBuf {
6146        self.root
6147            .join(STATE_DIR)
6148            .join("state")
6149            .join(format!("pr-{number}.json"))
6150    }
6151
6152    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
6153        let path = self.state_path(number);
6154        let text = std::fs::read_to_string(&path).ok()?;
6155        match serde_json::from_str(&text) {
6156            Ok(state) => Some(state),
6157            Err(_) => {
6158                logdim!("could not read {}, starting fresh", path.display());
6159                None
6160            }
6161        }
6162    }
6163
6164    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
6165        if let Some(local) = self.read_local_state(pr.number) {
6166            return Some(local);
6167        }
6168        if self.state_store.writes_pr() {
6169            return self.read_pr_state(pr.number);
6170        }
6171        None
6172    }
6173
6174    pub(crate) fn read_state_for_head(
6175        &self,
6176        pr: &PrView,
6177        actual_head: &str,
6178    ) -> Option<PersistedState> {
6179        let local = self
6180            .state_store
6181            .writes_local()
6182            .then(|| self.read_local_state(pr.number))
6183            .flatten();
6184        let remote = self
6185            .state_store
6186            .writes_pr()
6187            .then(|| self.read_pr_state(pr.number))
6188            .flatten();
6189        let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
6190        if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
6191            self.remember_checkpoint(pr.number, checkpoint);
6192        }
6193        choose_state_for_head(candidates, actual_head)
6194    }
6195
6196    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
6197        self.try_read_pr_state(number).ok().flatten()
6198    }
6199
6200    fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
6201        for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
6202            if let Some(state) = parse_state_comment(&body) {
6203                return Ok(Some(state));
6204            }
6205        }
6206        Ok(None)
6207    }
6208
6209    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6210        let remote_state = if self.state_store.writes_pr() {
6211            self.try_read_pr_state(number)
6212        } else {
6213            Ok(None)
6214        };
6215        self.write_state_after_remote_read(number, state, remote_state)
6216    }
6217
6218    fn write_state_after_remote_read(
6219        &self,
6220        number: i64,
6221        state: &PersistedState,
6222        remote_state: Result<Option<PersistedState>>,
6223    ) -> Result<()> {
6224        let remote_checkpoint = if self.state_store.writes_pr() {
6225            self.record_failed_write(remote_state)?
6226                .map(|saved| saved.checkpoint)
6227                .unwrap_or_default()
6228        } else {
6229            0
6230        };
6231        let local_checkpoint = self
6232            .state_store
6233            .writes_local()
6234            .then(|| self.read_local_state(number))
6235            .flatten()
6236            .map(|saved| saved.checkpoint)
6237            .unwrap_or_default();
6238        let mut stamped = state.clone();
6239        stamped.checkpoint = state
6240            .checkpoint
6241            .max(local_checkpoint)
6242            .max(remote_checkpoint)
6243            .max(self.remembered_checkpoint(number))
6244            .saturating_add(1);
6245        self.remember_checkpoint(number, stamped.checkpoint);
6246        if self.state_store.writes_local() {
6247            write_json_atomic(&self.state_path(number), &stamped)?;
6248        }
6249        if self.state_store.writes_pr() {
6250            self.write_pr_state(number, &stamped)?;
6251        }
6252        Ok(())
6253    }
6254
6255    fn remembered_checkpoint(&self, number: i64) -> u64 {
6256        self.checkpoints
6257            .lock()
6258            .unwrap_or_else(std::sync::PoisonError::into_inner)
6259            .get(&number)
6260            .copied()
6261            .unwrap_or_default()
6262    }
6263
6264    fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
6265        let mut checkpoints = self
6266            .checkpoints
6267            .lock()
6268            .unwrap_or_else(std::sync::PoisonError::into_inner);
6269        let saved = checkpoints.entry(number).or_default();
6270        *saved = (*saved).max(checkpoint);
6271    }
6272
6273    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6274        // Not run through clean(): this is structured data, and scrubbing would
6275        // corrupt refutation text stored in the ledger. It sits inside an
6276        // unclosed HTML comment so GitHub renders it as nothing.
6277        let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
6278        let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
6279        let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
6280        if let Some(id) = comment_id {
6281            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6282            let field = format!("body={body}");
6283            let written = self
6284                .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
6285                .map(|_| ());
6286            return self.record_write(written);
6287        }
6288        let written = self
6289            .gh(&["pr", "comment", &number.to_string(), "--body", &body])
6290            .map(|_| ());
6291        self.record_write(written)
6292    }
6293
6294    /// Top level comments. Works for issues and pull requests alike, because
6295    /// GitHub serves both from the issues endpoint.
6296    ///
6297    /// Nothing when they cannot be read, which suits a reader that is going to
6298    /// go on regardless. A caller deciding whether it has already written here
6299    /// wants `try_issue_comments`, since for that one no comments and no answer
6300    /// are opposite answers.
6301    pub fn issue_comments(&self, number: i64) -> Vec<Value> {
6302        self.try_issue_comments(number).unwrap_or_default()
6303    }
6304
6305    pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
6306        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
6307        try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
6308    }
6309
6310    /// An issue as the REST API returns it, which is the one read that says
6311    /// who filed it in the shape a comment has: `user` with its `type`, and
6312    /// `author_association`, neither of which `gh issue view` gives.
6313    pub fn issue_row(&self, number: i64) -> Result<Value> {
6314        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
6315        let text = self
6316            .gh(&["api", &path])
6317            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
6318        serde_json::from_str(&text)
6319            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
6320    }
6321
6322    /// Open issues in this repository that mention `login`, lowest numbered
6323    /// first.
6324    pub fn open_issues_mentioning(&self, login: &str, limit: usize) -> Result<Vec<i64>> {
6325        #[derive(Deserialize)]
6326        struct Row {
6327            number: i64,
6328        }
6329        // `{owner}/{repo}` is filled in by `gh api` alone.
6330        let name = self
6331            .repo_name()
6332            .ok_or_else(|| spar_err!("could not find out which repository this is"))?;
6333        let text = self.gh(&[
6334            "search",
6335            "issues",
6336            "--repo",
6337            &name,
6338            "--mentions",
6339            login,
6340            "--state",
6341            "open",
6342            "--limit",
6343            &FETCH_CEILING.to_string(),
6344            "--json",
6345            "number",
6346        ])?;
6347        let rows: Vec<Row> = serde_json::from_str(text.trim())
6348            .map_err(|e| spar_err!("unexpected shape for the issues mentioning @{login}: {e}"))?;
6349        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
6350        numbers.sort_unstable();
6351        numbers.truncate(limit);
6352        Ok(numbers)
6353    }
6354
6355    /// spar's own state comments on a pull request, newest last.
6356    ///
6357    /// Three tests, because the answer decides what gets patched over and what
6358    /// gets deleted. A comment matching the marker loosely, which is what
6359    /// somebody asking "why is there a `<!-- spar:state` block on this PR?"
6360    /// writes, is named in the log and left exactly where it is.
6361    fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
6362        let viewer = self.viewer_login()?.to_string();
6363        let mut mine = Vec::new();
6364        let mut theirs = Vec::new();
6365        for comment in self.try_issue_comments(number)? {
6366            let Some(body) = comment.get("body").and_then(Value::as_str) else {
6367                continue;
6368            };
6369            if !body.contains("spar:state") {
6370                continue;
6371            }
6372            let Some(id) = comment.get("id").and_then(Value::as_i64) else {
6373                continue;
6374            };
6375            let author = comment
6376                .get("user")
6377                .and_then(|u| u.get("login"))
6378                .and_then(Value::as_str)
6379                .unwrap_or_default();
6380            if is_state_comment(body, author, &viewer) {
6381                mine.push((id, body.to_string()));
6382            } else {
6383                theirs.push(format!("@{author}"));
6384            }
6385        }
6386        if !theirs.is_empty() {
6387            logdim!(
6388                "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
6389                 own state block is patched or deleted.",
6390                theirs.len(),
6391                theirs.join(", ")
6392            );
6393        }
6394        Ok(mine)
6395    }
6396
6397    fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
6398        Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
6399    }
6400
6401    /// Drop state once the PR is finished and there is nothing to resume.
6402    pub fn clear_state(&self, number: i64) {
6403        let path = self.state_path(number);
6404        let _ = std::fs::remove_file(&path);
6405        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
6406    }
6407
6408    // -- housekeeping -----------------------------------------------------
6409
6410    /// Remove state files whose PR is merged or closed: the custody loop's
6411    /// state, the review record, and the check-in watermark alike. Grouped by
6412    /// number so each finished pull request costs one `gh` read however many
6413    /// files it left.
6414    pub fn prune_state(&self) -> Vec<String> {
6415        let base = self.root.join(STATE_DIR).join("state");
6416        let Ok(entries) = std::fs::read_dir(&base) else {
6417            return Vec::new();
6418        };
6419        let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
6420        for name in entries
6421            .flatten()
6422            .filter_map(|e| e.file_name().to_str().map(str::to_string))
6423        {
6424            if let Some(number) = state_file_number(&name) {
6425                by_number.entry(number).or_default().push(name);
6426            }
6427        }
6428
6429        let mut removed = Vec::new();
6430        for (number, mut names) in by_number {
6431            if !is_finished(&self.pr_state(number)) {
6432                continue;
6433            }
6434            names.sort();
6435            for name in names {
6436                let _ = std::fs::remove_file(base.join(&name));
6437                removed.push(format!("state {name}"));
6438            }
6439        }
6440        removed
6441    }
6442
6443    /// Delete state comments from PRs that are finished.
6444    ///
6445    /// Open PRs are left alone: their state may still be live.
6446    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
6447        #[derive(Deserialize)]
6448        struct Row {
6449            number: i64,
6450        }
6451        let numbers = match numbers {
6452            Some(numbers) => numbers,
6453            None => {
6454                let listed: Result<Vec<i64>> = (|| {
6455                    let text = self.gh(&[
6456                        "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
6457                    ])?;
6458                    let rows = serde_json::from_str::<Vec<Row>>(text.trim())
6459                        .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
6460                    Ok(rows.into_iter().map(|row| row.number).collect())
6461                })();
6462                match self.record_failed_write(listed) {
6463                    Ok(numbers) => numbers,
6464                    Err(e) => {
6465                        logdim!("could not inspect pull requests for state cleanup: {e}");
6466                        return Vec::new();
6467                    }
6468                }
6469            }
6470        };
6471
6472        let mut removed = Vec::new();
6473        for number in numbers {
6474            let state = match self.record_failed_write(self.try_pr_state(number)) {
6475                Ok(state) => state,
6476                Err(e) => {
6477                    logdim!("could not inspect PR #{number} for state cleanup: {e}");
6478                    continue;
6479                }
6480            };
6481            if !is_finished(&state) {
6482                continue;
6483            }
6484            let comments = match self.record_failed_write(self.try_state_comments(number)) {
6485                Ok(comments) => comments,
6486                Err(e) => {
6487                    logdim!("could not inspect state comments on PR #{number}: {e}");
6488                    continue;
6489                }
6490            };
6491            for (id, _) in comments {
6492                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6493                let deleted = self
6494                    .gh(&["api", "-X", "DELETE", &path, "--silent"])
6495                    .map(|_| ());
6496                match self.record_write(deleted) {
6497                    Ok(()) => removed.push(format!("state comment on PR #{number}")),
6498                    Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
6499                }
6500            }
6501        }
6502        removed
6503    }
6504
6505    /// Drop worktrees whose PR is finished, then the branches they left behind.
6506    ///
6507    /// With auto_merge off, which is the default, a run ends at "approved", so
6508    /// nothing would ever clean these up on its own and they accumulate one per
6509    /// run. A stranded worktree also holds its branch checked out, which makes
6510    /// a later `gh pr merge --delete-branch` fail to clean up.
6511    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
6512        let base = self.root.join(WORKTREE_DIR);
6513        let mut removed = Vec::new();
6514        let known = self.known_branches();
6515
6516        if let Ok(entries) = std::fs::read_dir(&base) {
6517            let mut names: Vec<String> = entries
6518                .flatten()
6519                .filter(|e| e.path().is_dir())
6520                .filter_map(|e| e.file_name().to_str().map(str::to_string))
6521                .collect();
6522            names.sort();
6523
6524            for name in names {
6525                // A read checkout is released by the run that made it, so one
6526                // still here was either kept for recovery or left by a run
6527                // that never got to release it.
6528                if let Some(owner) = read_checkout_owner(&name) {
6529                    let path = base.join(&name);
6530                    if force_all {
6531                        self.remove_worktree_at_force(&path);
6532                    } else if process_is_running(owner) {
6533                        continue;
6534                    } else if has_recovery_marker(&path) {
6535                        logdim!("kept {} for recovery", path.display());
6536                        continue;
6537                    } else {
6538                        self.discard_read_checkout(&path);
6539                    }
6540                    if !path.exists() {
6541                        removed.push(name);
6542                    }
6543                    continue;
6544                }
6545                // A review worktree is detached and owns no branch, so it is
6546                // tied to the pull request only by its directory name.
6547                if let Some(rest) = name.strip_prefix("review-") {
6548                    let number: i64 = rest.parse().unwrap_or(-1);
6549                    if !(force_all || is_finished(&self.pr_state(number))) {
6550                        continue;
6551                    }
6552                    let path = base.join(&name);
6553                    if force_all {
6554                        let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
6555                            if !belongs {
6556                                return Ok(false);
6557                            }
6558                            let local_ref = review_ref(number);
6559                            if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
6560                                return Ok(false);
6561                            }
6562                            let head = self.head_oid_checked(&path)?;
6563                            let recorded = self
6564                                .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
6565                                .trim()
6566                                .to_string();
6567                            Ok(head == recorded)
6568                        });
6569                        match owned {
6570                            Ok(true) => {}
6571                            Ok(false) => {
6572                                logdim!(
6573                                    "kept {} because no matching SPAR review reference proves \
6574                                     ownership",
6575                                    path.display()
6576                                );
6577                                continue;
6578                            }
6579                            Err(e) => {
6580                                logdim!(
6581                                    "kept {} because review ownership could not be verified: {}",
6582                                    path.display(),
6583                                    e.last_line()
6584                                );
6585                                continue;
6586                            }
6587                        }
6588                    } else {
6589                        if let Err(e) = self.refuse_review_worktree_changes(number) {
6590                            logdim!(
6591                                "kept {} because its review state could not be verified as \
6592                                 disposable: {}",
6593                                path.display(),
6594                                e.last_line()
6595                            );
6596                            continue;
6597                        }
6598                    }
6599                    if force_all {
6600                        if self.remove_worktree_at_force(&path) {
6601                            self.git_try(&["update-ref", "-d", &review_ref(number)]);
6602                        }
6603                    } else {
6604                        self.release_review_worktree(number);
6605                    }
6606                    if !path.exists() {
6607                        removed.push(name);
6608                    }
6609                    continue;
6610                }
6611                let branch = format!("{}{name}", self.branch_prefix);
6612                if !(force_all || self.worktree_is_done(&branch)) {
6613                    continue;
6614                }
6615                if !known.contains_key(&branch) {
6616                    logdim!("kept {branch} because it has no branch record");
6617                    continue;
6618                }
6619                let path = base.join(&name);
6620                if !force_all {
6621                    match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6622                        Ok(true) => {
6623                            logdim!(
6624                                "kept {} because it contains uncommitted changes or ignored files",
6625                                path.display()
6626                            );
6627                            continue;
6628                        }
6629                        Err(e) => {
6630                            logdim!(
6631                                "kept {} because its Git state could not be checked: {}",
6632                                path.display(),
6633                                e.last_line()
6634                            );
6635                            continue;
6636                        }
6637                        Ok(false) => {}
6638                    }
6639                    match self.branch_deletion_is_safe(&branch) {
6640                        Ok(true) => {}
6641                        Ok(false) => {
6642                            logdim!(
6643                                "kept {branch} because no surviving ref preserves its tip or \
6644                                 reflog-only commits"
6645                            );
6646                            continue;
6647                        }
6648                        Err(e) => {
6649                            logdim!(
6650                                "kept {branch} because preservation could not be verified: {}",
6651                                e.last_line()
6652                            );
6653                            continue;
6654                        }
6655                    }
6656                }
6657                let removed_worktree = if force_all {
6658                    self.remove_worktree_at_force(&path)
6659                } else {
6660                    match self.remove_worktree_at(&path) {
6661                        Ok(removed) => removed,
6662                        Err(error) => {
6663                            logdim!(
6664                                "kept {branch} and {} because removal did not reach a confirmed \
6665                                 quiet point: {}",
6666                                path.display(),
6667                                error.last_line()
6668                            );
6669                            false
6670                        }
6671                    }
6672                };
6673                if !removed_worktree {
6674                    continue;
6675                }
6676                if force_all {
6677                    self.git_try(&["branch", "-D", &branch]);
6678                    self.forget_branch(&branch);
6679                } else {
6680                    match self.delete_branch_if_safe(&branch) {
6681                        Ok(true) => self.forget_branch(&branch),
6682                        Ok(false) => logdim!(
6683                            "kept {branch} because its tip or reflog changed before deletion"
6684                        ),
6685                        Err(error) => logdim!(
6686                            "kept {branch} because deletion safety could not be rechecked: {}",
6687                            error.last_line()
6688                        ),
6689                    }
6690                }
6691                removed.push(name);
6692            }
6693        }
6694        removed.extend(self.prune_branches(force_all));
6695        removed
6696    }
6697
6698    /// Delete leftover branches spar created whose worktree is already gone.
6699    ///
6700    /// Deletion is driven by the ledger of branches spar actually created, not
6701    /// by a name pattern. Names default to `issue-N`, which is exactly what a
6702    /// person would call a branch themselves, so a name alone can never
6703    /// establish ownership. This is the data loss guard.
6704    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
6705        let known = self.known_branches();
6706        let branches: Vec<String> = known.keys().cloned().collect();
6707        if branches.is_empty() {
6708            return Vec::new();
6709        }
6710
6711        let checked_out: Vec<String> = self
6712            .git_try(&["worktree", "list", "--porcelain"])
6713            .lines()
6714            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
6715            .collect();
6716
6717        // %(refname:short) is ambiguous when a tag shares the branch name (it
6718        // yields "heads/..."), so take the full ref and strip it here.
6719        let existing: Vec<String> = self
6720            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
6721            .lines()
6722            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
6723            .collect();
6724
6725        let mut removed = Vec::new();
6726        for branch in branches {
6727            if !existing.contains(&branch) {
6728                self.forget_branch(&branch); // already gone, drop the record
6729                continue;
6730            }
6731            if checked_out.contains(&branch) {
6732                continue;
6733            }
6734            if !(force_all || self.worktree_is_done(&branch)) {
6735                continue;
6736            }
6737            if !force_all {
6738                let Some(_record) = known.get(&branch) else {
6739                    continue;
6740                };
6741                match self.branch_deletion_is_safe(&branch) {
6742                    Ok(true) => {}
6743                    Ok(false) => {
6744                        logdim!(
6745                            "kept {branch} because no surviving ref preserves its tip or \
6746                             reflog-only commits"
6747                        );
6748                        continue;
6749                    }
6750                    Err(e) => {
6751                        logdim!(
6752                            "kept {branch} because preservation could not be verified: {}",
6753                            e.last_line()
6754                        );
6755                        continue;
6756                    }
6757                }
6758            }
6759            let deleted = if force_all {
6760                self.git(&["branch", "-D", &branch]).map(|_| true)
6761            } else {
6762                self.delete_branch_if_safe(&branch)
6763            };
6764            match deleted {
6765                Ok(true) => {
6766                    self.forget_branch(&branch);
6767                    removed.push(format!("branch {branch}"));
6768                }
6769                Ok(false) => {
6770                    logdim!("kept {branch} because its tip or reflog changed before deletion");
6771                }
6772                Err(e) => {
6773                    // A branch that silently survives pruning looks like a spar
6774                    // bug, so the name and git's own reason have to be said.
6775                    logdim!("could not delete {branch}: {}", e.last_line());
6776                }
6777            }
6778        }
6779        removed
6780    }
6781
6782    /// True when the PR behind this branch is merged or closed.
6783    fn worktree_is_done(&self, branch: &str) -> bool {
6784        #[derive(Deserialize)]
6785        struct Row {
6786            state: String,
6787        }
6788        let entry = branch
6789            .strip_prefix(self.branch_prefix.as_str())
6790            .unwrap_or(branch);
6791        if let Some(rest) = entry.strip_prefix("pr-") {
6792            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
6793        }
6794        // A split part is the same shape as an issue branch: one branch, whose
6795        // pull requests say whether it is finished. Without it here, a part
6796        // branch is one nothing but `clean --all` would ever remove.
6797        if entry.starts_with("issue-") || entry.starts_with("split-") {
6798            let text = self.gh_try(&[
6799                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
6800            ]);
6801            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
6802            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
6803        }
6804        false
6805    }
6806}
6807
6808// ---------------------------------------------------------------------------
6809// Free helpers
6810// ---------------------------------------------------------------------------
6811
6812/// Take a read-only probe again before treating its failure as evidence.
6813///
6814/// Every probe is a Git invocation under a thirty second cap plus a walk over a
6815/// tree an agent may still be building in. A probe that fails proves nothing
6816/// about the worktree, and there is a real difference between "the tree moved"
6817/// and "the question could not be asked just then", which the caller turns into
6818/// the same run-ending error.
6819pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
6820    match probe() {
6821        Ok(value) => Ok(value),
6822        Err(first) => {
6823            logdim!("retrying a worktree probe after: {}", first.last_line());
6824            probe()
6825        }
6826    }
6827}
6828
6829/// Read attribute files without asking Git to inspect working-tree content.
6830///
6831/// A newly written attribute can select a clean or smudge filter. It must be
6832/// detected before a post-call status, diff, or add command has a chance to run
6833/// that filter in the parent process.
6834pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
6835    let root = std::fs::canonicalize(cwd)
6836        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6837    let mut files = BTreeMap::new();
6838    let mut visited = BTreeSet::new();
6839    collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
6840    Ok(AttributeState { files })
6841}
6842
6843fn collect_attribute_files(
6844    root: &Path,
6845    repository: &Path,
6846    prefix: &Path,
6847    visited: &mut BTreeSet<PathBuf>,
6848    files: &mut BTreeMap<PathBuf, [u8; 32]>,
6849) -> Result<()> {
6850    let canonical = std::fs::canonicalize(repository)
6851        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6852    if !visited.insert(canonical) {
6853        bail!("submodule recursion revisited {}", repository.display());
6854    }
6855    let entries = index_entries(repository)?;
6856    let mut paths: BTreeSet<PathBuf> = entries
6857        .iter()
6858        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6859        .map(|entry| entry.path.clone())
6860        .collect();
6861    // Ignored on purpose. A `.gitattributes` under build output can only select
6862    // filters for paths that are themselves ignored, and no ignored path is
6863    // ever staged or compared as tracked content, so it cannot reach a managed
6864    // commit. Capturing it meant `cargo package` or `npm ci` writing one into
6865    // its own output ended a read-only call outright.
6866    paths.extend(untracked_attribute_paths(repository)?);
6867    for path in paths {
6868        let from_root = prefix.join(&path);
6869        let state = attribute_file_fingerprint(&root.join(&from_root))?;
6870        files.insert(from_root, state);
6871    }
6872    for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
6873        let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
6874            continue;
6875        };
6876        collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
6877    }
6878    Ok(())
6879}
6880
6881/// Leave a visible, untracked reason ordinary cleanup can detect on a later
6882/// run even when Git status normalizes the original working-file change away.
6883pub(crate) fn uncertain_worktree_change(
6884    cwd: &Path,
6885    message: impl Into<String>,
6886) -> crate::error::SparError {
6887    let message = message.into();
6888    let marker = write_recovery_marker(cwd, &message);
6889    let note = match marker {
6890        Ok(path) => format!(" Recovery marker: {}.", path.display()),
6891        Err(e) => format!(
6892            " A recovery marker could not be written: {}.",
6893            e.last_line()
6894        ),
6895    };
6896    crate::error::SparError::uncertain_write(format!("{message}{note}"))
6897}
6898
6899fn has_recovery_marker(cwd: &Path) -> bool {
6900    let Ok(entries) = std::fs::read_dir(cwd) else {
6901        return false;
6902    };
6903    entries.flatten().any(|entry| {
6904        entry
6905            .file_name()
6906            .to_str()
6907            .is_some_and(|name| name.starts_with(RECOVERY_MARKER_PREFIX))
6908    })
6909}
6910
6911/// The process a read checkout's directory name says made it.
6912fn read_checkout_owner(name: &str) -> Option<u32> {
6913    let rest = name.strip_prefix(READ_CHECKOUT_PREFIX)?;
6914    let (pid, serial) = rest.split_once('-')?;
6915    serial.parse::<usize>().ok()?;
6916    pid.parse().ok()
6917}
6918
6919/// Whether a process may still be running.
6920///
6921/// Anything short of proof that it is gone says yes. A wrong yes leaves a
6922/// checkout for `spar clean --all`; a wrong no deletes one under a running
6923/// call.
6924#[cfg(unix)]
6925fn process_is_running(pid: u32) -> bool {
6926    let Ok(pid) = libc::pid_t::try_from(pid) else {
6927        return true;
6928    };
6929    if pid <= 0 {
6930        return true;
6931    }
6932    if unsafe { libc::kill(pid, 0) } == 0 {
6933        return true;
6934    }
6935    std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
6936}
6937
6938#[cfg(not(unix))]
6939fn process_is_running(_pid: u32) -> bool {
6940    true
6941}
6942
6943fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
6944    use std::sync::atomic::{AtomicU32, Ordering};
6945    static NEXT: AtomicU32 = AtomicU32::new(0);
6946    for _ in 0..1000 {
6947        let serial = NEXT.fetch_add(1, Ordering::Relaxed);
6948        let path = cwd.join(format!(
6949            "{RECOVERY_MARKER_PREFIX}{}-{serial}",
6950            std::process::id()
6951        ));
6952        let mut options = OpenOptions::new();
6953        options.write(true).create_new(true);
6954        #[cfg(unix)]
6955        {
6956            use std::os::unix::fs::OpenOptionsExt;
6957            options.mode(0o600);
6958        }
6959        match options.open(&path) {
6960            Ok(mut file) => {
6961                file.write_all(detail.as_bytes())
6962                    .and_then(|_| file.write_all(b"\n"))
6963                    .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6964                return Ok(path);
6965            }
6966            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
6967            Err(e) => {
6968                return Err(spar_err!(
6969                    "could not create a recovery marker in {}: {e}",
6970                    cwd.display()
6971                ))
6972            }
6973        }
6974    }
6975    bail!(
6976        "could not choose a free recovery marker name in {}",
6977        cwd.display()
6978    )
6979}
6980
6981/// Build a Git command that cannot launch automatic repository maintenance.
6982///
6983/// A fetch may otherwise prune missing linked worktree registrations. SPAR
6984/// must only remove registrations it has proven it owns.
6985fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
6986    let mut argv = vec![
6987        "git".to_string(),
6988        "-c".to_string(),
6989        "maintenance.auto=false".to_string(),
6990        "-c".to_string(),
6991        "gc.auto=0".to_string(),
6992    ];
6993    argv.extend(args.iter().map(|arg| (*arg).to_string()));
6994    argv
6995}
6996
6997fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
6998    let mut argv = git_without_maintenance_argv(&[]);
6999    argv.extend([
7000        "-c".to_string(),
7001        "core.fsmonitor=".to_string(),
7002        "-c".to_string(),
7003        "commit.gpgsign=false".to_string(),
7004        "-c".to_string(),
7005        "core.hooksPath=/dev/null".to_string(),
7006    ]);
7007    argv.extend(args.iter().map(|arg| (*arg).to_string()));
7008    argv
7009}
7010
7011/// Snapshot every untracked file, including ignored files, without changing
7012/// path bytes.
7013///
7014/// Without an exclude option, Git lists both ordinary and ignored untracked
7015/// entries. Metadata fingerprints make overwriting an existing path observable
7016/// without hashing a potentially multi-gigabyte build tree on every call.
7017pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
7018    let root = std::fs::canonicalize(cwd)
7019        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7020    let mut files = BTreeMap::new();
7021    let mut ignored = BTreeSet::new();
7022    let mut visited = BTreeSet::new();
7023    collect_untracked_files(
7024        &root,
7025        &root,
7026        Path::new(""),
7027        &mut visited,
7028        &mut files,
7029        &mut ignored,
7030    )?;
7031    Ok(IgnoredState { files, ignored })
7032}
7033
7034fn collect_untracked_files(
7035    root: &Path,
7036    repository: &Path,
7037    prefix: &Path,
7038    visited: &mut BTreeSet<PathBuf>,
7039    files: &mut BTreeMap<PathBuf, UntrackedFile>,
7040    ignored: &mut BTreeSet<PathBuf>,
7041) -> Result<()> {
7042    let canonical = std::fs::canonicalize(repository)
7043        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7044    if !visited.insert(canonical.clone()) {
7045        bail!("submodule recursion revisited {}", canonical.display());
7046    }
7047    // Classify ordinary paths in the listing itself. Listing every path here
7048    // and marking ignored paths in a second query misclassifies build output
7049    // deleted between queries as ordinary untracked work.
7050    let listed = run_git_bytes(
7051        repository,
7052        &["ls-files", "--others", "--exclude-standard", "-z"],
7053    )?;
7054    if !listed.is_empty() && !listed.ends_with(&[0]) {
7055        bail!(
7056            "git returned an unterminated untracked-file list for {}",
7057            repository.display()
7058        );
7059    }
7060
7061    for raw in listed
7062        .split(|byte| *byte == 0)
7063        .filter(|raw| !raw.is_empty())
7064    {
7065        let (relative, nested) = untracked_record(raw, "untracked")?;
7066        let from_root = prefix.join(&relative);
7067        if is_spar_own_path(&from_root) {
7068            continue;
7069        }
7070        let absolute = root.join(&from_root);
7071        let fingerprint = if nested {
7072            nested_repository_fingerprint(&absolute)?
7073        } else {
7074            ignored_file_fingerprint(&absolute)?
7075        };
7076        if files.insert(from_root.clone(), fingerprint).is_some() {
7077            bail!(
7078                "git returned the untracked path more than once: {:?}",
7079                from_root
7080            );
7081        }
7082    }
7083
7084    let ignored_listed = run_git_bytes(
7085        repository,
7086        &[
7087            "ls-files",
7088            "--others",
7089            "--ignored",
7090            "--exclude-standard",
7091            "-z",
7092        ],
7093    )?;
7094    if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
7095        bail!(
7096            "git returned an unterminated ignored-file list for {}",
7097            repository.display()
7098        );
7099    }
7100    // Two `ls-files` runs cannot be made to agree about a tree that is being
7101    // built in. A path named only by the second one was created between them,
7102    // which is a fact about the moment, not a corruption: record it and let the
7103    // comparison decide, rather than ending a run that had nothing wrong with
7104    // it.
7105    for raw in ignored_listed
7106        .split(|byte| *byte == 0)
7107        .filter(|raw| !raw.is_empty())
7108    {
7109        let (relative, nested) = untracked_record(raw, "ignored")?;
7110        let from_root = prefix.join(relative);
7111        if is_spar_own_path(&from_root) {
7112            continue;
7113        }
7114        if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
7115            let absolute = root.join(&from_root);
7116            let fingerprint = if nested {
7117                nested_repository_fingerprint(&absolute)?
7118            } else {
7119                ignored_file_fingerprint(&absolute)?
7120            };
7121            slot.insert(fingerprint);
7122        }
7123        if !ignored.insert(from_root.clone()) {
7124            bail!(
7125                "git returned the ignored path more than once: {:?}",
7126                from_root
7127            );
7128        }
7129    }
7130
7131    for link in gitlinks(repository)? {
7132        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7133            continue;
7134        };
7135        collect_untracked_files(
7136            root,
7137            &submodule,
7138            &prefix.join(&link.path),
7139            visited,
7140            files,
7141            ignored,
7142        )?;
7143    }
7144    Ok(())
7145}
7146
7147fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
7148    let argv = git_without_automation_argv(args);
7149    proc::run_bytes(
7150        &argv,
7151        &ExecOpts::new()
7152            .cwd(cwd)
7153            .timeout_secs(30)
7154            .stop_descendants(true),
7155    )
7156}
7157
7158fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
7159    let argv = git_without_automation_argv(args);
7160    proc::run(
7161        &argv,
7162        &ExecOpts::new()
7163            .cwd(cwd)
7164            .timeout_secs(30)
7165            .stop_descendants(true),
7166    )
7167}
7168
7169fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
7170    let path = path.to_str().ok_or_else(|| {
7171        spar_err!(
7172            "cannot verify filtered content for a non-UTF-8 path in {}",
7173            cwd.display()
7174        )
7175    })?;
7176    let path_arg = format!("--path={path}");
7177    let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
7178    Ok(Sha256::digest(bytes).into())
7179}
7180
7181fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
7182    let relative = path_from_git_bytes(raw)?;
7183    if relative.is_absolute()
7184        || relative.components().any(|component| {
7185            matches!(
7186                component,
7187                std::path::Component::ParentDir
7188                    | std::path::Component::RootDir
7189                    | std::path::Component::Prefix(_)
7190            )
7191        })
7192    {
7193        bail!("git returned an unsafe {kind} path: {:?}", relative);
7194    }
7195    Ok(relative)
7196}
7197
7198/// Split one `ls-files --others` record into its path and whether Git reported
7199/// a nested repository rather than a single file.
7200///
7201/// Git never lists the contents of a repository inside the working tree, so a
7202/// checkout parked there, such as another of SPAR's own worktrees, arrives as
7203/// one record for the directory itself ending in a separator. Git writes that
7204/// separator on every platform. Trimming it keeps the recorded path equal to
7205/// the same path seen any other way.
7206fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
7207    let nested = raw.last() == Some(&b'/');
7208    let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
7209    if trimmed.is_empty() {
7210        bail!("git returned an empty {kind} path");
7211    }
7212    Ok((safe_git_path(trimmed, kind)?, nested))
7213}
7214
7215fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
7216    let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
7217    if !listed.is_empty() && !listed.ends_with(&[0]) {
7218        bail!(
7219            "git returned an unterminated index listing for {}",
7220            cwd.display()
7221        );
7222    }
7223    let mut entries = Vec::new();
7224    for record in listed
7225        .split(|byte| *byte == 0)
7226        .filter(|record| !record.is_empty())
7227    {
7228        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7229            bail!(
7230                "git returned a malformed index record for {}",
7231                cwd.display()
7232            );
7233        };
7234        let header = &record[..tab];
7235        let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
7236        if fields.len() != 3 {
7237            bail!(
7238                "git returned a malformed index header for {}",
7239                cwd.display()
7240            );
7241        }
7242        if fields[2] != b"0" {
7243            continue;
7244        }
7245        let mode = std::str::from_utf8(fields[0])
7246            .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
7247            .to_string();
7248        let oid = std::str::from_utf8(fields[1])
7249            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7250            .to_string();
7251        entries.push(IndexEntry {
7252            path: safe_git_path(&record[tab + 1..], "index")?,
7253            mode,
7254            oid,
7255        });
7256    }
7257    Ok(entries)
7258}
7259
7260/// Share Git's complete ignore policy between call checkpoints and handoffs.
7261fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
7262    let listed = run_git_bytes(
7263        cwd,
7264        &[
7265            "ls-files",
7266            "--others",
7267            "--exclude-standard",
7268            "-z",
7269            "--",
7270            ".gitattributes",
7271            ":(glob)**/.gitattributes",
7272        ],
7273    )?;
7274    if !listed.is_empty() && !listed.ends_with(&[0]) {
7275        bail!(
7276            "git returned an unterminated attribute-file listing for {}",
7277            cwd.display()
7278        );
7279    }
7280    listed
7281        .split(|byte| *byte == 0)
7282        .filter(|record| !record.is_empty())
7283        .map(|raw| safe_git_path(raw, "attribute"))
7284        .collect()
7285}
7286
7287fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
7288    if !untracked_attribute_paths(cwd)?.is_empty() {
7289        return Ok(true);
7290    }
7291
7292    let index = index_entries(cwd)?
7293        .into_iter()
7294        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7295        .map(|entry| (entry.path, (entry.mode, entry.oid)))
7296        .collect::<BTreeMap<_, _>>();
7297    let head = tree_entries(cwd, "HEAD")?
7298        .into_iter()
7299        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7300        .map(|entry| (entry.path, (entry.mode, entry.oid)))
7301        .collect::<BTreeMap<_, _>>();
7302    if index != head {
7303        return Ok(true);
7304    }
7305
7306    let effective = check_attributes(cwd, index.keys().cloned())?;
7307    let config = CheckoutConfig::read(cwd)?;
7308    for (path, (_mode, oid)) in index {
7309        let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
7310            return Ok(true);
7311        };
7312        let attributes = effective
7313            .get(&path)
7314            .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
7315        if allows_expected_crlf(&config, attributes)? {
7316            if worktree.mode == "120000" {
7317                return Ok(true);
7318            }
7319            let (normalized, every_lf_was_crlf) =
7320                normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
7321            if !every_lf_was_crlf || normalized != oid {
7322                return Ok(true);
7323            }
7324        } else if worktree.raw_oid != oid {
7325            return Ok(true);
7326        }
7327    }
7328    Ok(false)
7329}
7330
7331fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
7332    Ok(index_entries(cwd)?
7333        .into_iter()
7334        .filter(|entry| entry.mode == "160000")
7335        .map(|entry| Gitlink {
7336            path: entry.path,
7337            oid: entry.oid,
7338        })
7339        .collect())
7340}
7341
7342fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
7343    let mut tracked = BTreeMap::new();
7344    for entry in index_entries(cwd)? {
7345        if entry.mode == "160000" {
7346            continue;
7347        }
7348        let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
7349        tracked.insert(
7350            entry.path,
7351            TrackedEntry {
7352                index_mode: entry.mode,
7353                index_oid: entry.oid,
7354                worktree,
7355            },
7356        );
7357    }
7358    Ok(tracked)
7359}
7360
7361fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
7362    let metadata = match std::fs::symlink_metadata(path) {
7363        Ok(metadata) => metadata,
7364        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7365        Err(e) => {
7366            return Err(spar_err!(
7367                "could not inspect tracked file {}: {e}",
7368                path.display()
7369            ))
7370        }
7371    };
7372    let mut fingerprint = Sha256::new();
7373    if metadata.file_type().is_symlink() {
7374        let target = std::fs::read_link(path)
7375            .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
7376        let bytes = os_str_bytes(target.as_os_str())?;
7377        fingerprint.update(b"symlink\0");
7378        fingerprint.update(&bytes);
7379        let content = Sha256::digest(&bytes).into();
7380        return Ok(Some(WorktreeFile {
7381            mode: "120000".to_string(),
7382            #[cfg(unix)]
7383            permissions: 0,
7384            raw_oid: git_blob_oid(oid_len, &bytes)?,
7385            fingerprint: fingerprint.finalize().into(),
7386            content,
7387        }));
7388    }
7389    if !metadata.is_file() {
7390        bail!("tracked path {} is not a file or symlink", path.display());
7391    }
7392
7393    let mut options = OpenOptions::new();
7394    options.read(true);
7395    #[cfg(unix)]
7396    {
7397        use std::os::unix::fs::OpenOptionsExt;
7398        options.custom_flags(libc::O_NOFOLLOW);
7399    }
7400    let mut file = options
7401        .open(path)
7402        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7403    let before = file
7404        .metadata()
7405        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7406    let mode = tracked_file_mode(&before);
7407    #[cfg(unix)]
7408    let permissions = {
7409        use std::os::unix::fs::MetadataExt;
7410        before.mode() & 0o7777
7411    };
7412    fingerprint.update(b"file\0");
7413    fingerprint.update(mode.as_bytes());
7414    #[cfg(unix)]
7415    fingerprint.update(permissions.to_le_bytes());
7416    fingerprint.update(before.len().to_le_bytes());
7417    let mut content = Sha256::new();
7418    let header = format!("blob {}\0", before.len());
7419    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7420    let mut buf = [0u8; 64 * 1024];
7421    loop {
7422        let read = file
7423            .read(&mut buf)
7424            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7425        if read == 0 {
7426            break;
7427        }
7428        fingerprint.update(&buf[..read]);
7429        content.update(&buf[..read]);
7430        object.update(&buf[..read]);
7431    }
7432    let after = file
7433        .metadata()
7434        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7435    if before.len() != after.len()
7436        || before.modified().ok() != after.modified().ok()
7437        || before.permissions() != after.permissions()
7438    {
7439        bail!(
7440            "tracked file {} changed while it was being inspected",
7441            path.display()
7442        );
7443    }
7444    let current = std::fs::symlink_metadata(path)
7445        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7446    if !same_file(&after, &current) {
7447        bail!(
7448            "tracked file {} was replaced while it was being inspected",
7449            path.display()
7450        );
7451    }
7452    Ok(Some(WorktreeFile {
7453        mode,
7454        #[cfg(unix)]
7455        permissions,
7456        raw_oid: object.finish(),
7457        fingerprint: fingerprint.finalize().into(),
7458        content: content.finalize().into(),
7459    }))
7460}
7461
7462fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
7463    let metadata = std::fs::symlink_metadata(path)
7464        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7465    let mut digest = Sha256::new();
7466    if metadata.file_type().is_symlink() {
7467        digest.update(b"symlink\0");
7468        let target = std::fs::read_link(path)
7469            .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
7470        digest.update(os_str_bytes(target.as_os_str())?);
7471        return Ok(digest.finalize().into());
7472    }
7473    if !metadata.is_file() {
7474        bail!("attribute path {} is not a file or symlink", path.display());
7475    }
7476    let mut options = OpenOptions::new();
7477    options.read(true);
7478    #[cfg(unix)]
7479    {
7480        use std::os::unix::fs::OpenOptionsExt;
7481        options.custom_flags(libc::O_NOFOLLOW);
7482    }
7483    let mut file = options
7484        .open(path)
7485        .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7486    let before = file
7487        .metadata()
7488        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7489    digest.update(b"file\0");
7490    let mut buf = [0u8; 64 * 1024];
7491    loop {
7492        let read = file
7493            .read(&mut buf)
7494            .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7495        if read == 0 {
7496            break;
7497        }
7498        digest.update(&buf[..read]);
7499    }
7500    let after = file
7501        .metadata()
7502        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7503    let current = std::fs::symlink_metadata(path)
7504        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7505    if before.len() != after.len()
7506        || before.modified().ok() != after.modified().ok()
7507        || !same_file(&after, &current)
7508    {
7509        bail!(
7510            "attribute file {} changed while it was being inspected",
7511            path.display()
7512        );
7513    }
7514    Ok(digest.finalize().into())
7515}
7516
7517enum ObjectHasher {
7518    Sha1(Sha1),
7519    Sha256(Sha256),
7520}
7521
7522impl ObjectHasher {
7523    fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
7524        let mut hasher = match oid_len {
7525            40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
7526            64 => Self::Sha256(Sha256::new()),
7527            _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
7528        };
7529        hasher.update(header);
7530        Ok(hasher)
7531    }
7532
7533    fn update(&mut self, bytes: &[u8]) {
7534        match self {
7535            Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
7536            Self::Sha256(hasher) => hasher.update(bytes),
7537        }
7538    }
7539
7540    fn finish(self) -> String {
7541        let bytes = match self {
7542            Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
7543            Self::Sha256(hasher) => hasher.finalize().to_vec(),
7544        };
7545        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
7546    }
7547}
7548
7549fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
7550    let header = format!("blob {}\0", bytes.len());
7551    let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
7552    hasher.update(bytes);
7553    Ok(hasher.finish())
7554}
7555
7556fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
7557    let mut first = open_regular_file(path)?;
7558    let first_before = first
7559        .metadata()
7560        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7561    let mut raw_len = 0u64;
7562    let mut crlf_pairs = 0u64;
7563    let mut previous_was_cr = false;
7564    let mut every_lf_was_crlf = true;
7565    let mut buf = [0u8; 64 * 1024];
7566    loop {
7567        let read = first
7568            .read(&mut buf)
7569            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7570        if read == 0 {
7571            break;
7572        }
7573        raw_len = raw_len
7574            .checked_add(read as u64)
7575            .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
7576        for byte in &buf[..read] {
7577            if *byte == b'\n' {
7578                if previous_was_cr {
7579                    crlf_pairs += 1;
7580                } else {
7581                    every_lf_was_crlf = false;
7582                }
7583            }
7584            previous_was_cr = *byte == b'\r';
7585        }
7586    }
7587    let first_after = first
7588        .metadata()
7589        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7590    let current = std::fs::symlink_metadata(path)
7591        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7592    if raw_len != first_before.len()
7593        || !stable_file_metadata(&first_before, &first_after)
7594        || !stable_file_metadata(&first_after, &current)
7595    {
7596        bail!(
7597            "tracked file {} changed while line endings were inspected",
7598            path.display()
7599        );
7600    }
7601
7602    let normalized_len = raw_len
7603        .checked_sub(crlf_pairs)
7604        .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
7605    let header = format!("blob {normalized_len}\0");
7606    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7607    let mut second = open_regular_file(path)?;
7608    let second_before = second
7609        .metadata()
7610        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7611    if !stable_file_metadata(&first_after, &second_before) {
7612        bail!(
7613            "tracked file {} changed between line-ending checks",
7614            path.display()
7615        );
7616    }
7617    let mut pending_cr = false;
7618    loop {
7619        let read = second
7620            .read(&mut buf)
7621            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7622        if read == 0 {
7623            break;
7624        }
7625        for byte in &buf[..read] {
7626            if pending_cr {
7627                if *byte == b'\n' {
7628                    object.update(b"\n");
7629                    pending_cr = false;
7630                    continue;
7631                }
7632                object.update(b"\r");
7633                pending_cr = false;
7634            }
7635            if *byte == b'\r' {
7636                pending_cr = true;
7637            } else {
7638                object.update(std::slice::from_ref(byte));
7639            }
7640        }
7641    }
7642    if pending_cr {
7643        object.update(b"\r");
7644    }
7645    let second_after = second
7646        .metadata()
7647        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7648    let current = std::fs::symlink_metadata(path)
7649        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7650    if !stable_file_metadata(&second_before, &second_after)
7651        || !stable_file_metadata(&second_after, &current)
7652    {
7653        bail!(
7654            "tracked file {} changed while line endings were hashed",
7655            path.display()
7656        );
7657    }
7658    Ok((object.finish(), every_lf_was_crlf))
7659}
7660
7661fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7662    let mut options = OpenOptions::new();
7663    options.read(true);
7664    #[cfg(unix)]
7665    {
7666        use std::os::unix::fs::OpenOptionsExt;
7667        options.custom_flags(libc::O_NOFOLLOW);
7668    }
7669    let file = options
7670        .open(path)
7671        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7672    let metadata = file
7673        .metadata()
7674        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7675    if !metadata.is_file() {
7676        bail!("tracked path {} is not a regular file", path.display());
7677    }
7678    Ok(file)
7679}
7680
7681fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7682    if !same_file(left, right)
7683        || left.len() != right.len()
7684        || left.modified().ok() != right.modified().ok()
7685        || left.permissions() != right.permissions()
7686    {
7687        return false;
7688    }
7689    #[cfg(unix)]
7690    {
7691        use std::os::unix::fs::MetadataExt;
7692        left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
7693    }
7694    #[cfg(not(unix))]
7695    {
7696        left.created().ok() == right.created().ok()
7697    }
7698}
7699
7700/// How many paths one `check-attr` call is asked about.
7701///
7702/// `check-attr` answers with three NUL fields per attribute per path, so the
7703/// output is several times the input. A whole monorepo in one call is megabytes
7704/// in both directions, and the 30 second timeout below is a timeout on the
7705/// batch. Batching keeps each call small enough to be honest about.
7706const ATTRIBUTE_BATCH: usize = 1000;
7707
7708fn check_attributes(
7709    cwd: &Path,
7710    paths: impl IntoIterator<Item = PathBuf>,
7711) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7712    let paths = paths.into_iter().collect::<BTreeSet<_>>();
7713    if paths.is_empty() {
7714        return Ok(BTreeMap::new());
7715    }
7716    let ordered = paths.into_iter().collect::<Vec<_>>();
7717    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7718    for batch in ordered.chunks(ATTRIBUTE_BATCH) {
7719        values.extend(check_attribute_batch(cwd, batch)?);
7720    }
7721    Ok(values)
7722}
7723
7724fn check_attribute_batch(
7725    cwd: &Path,
7726    batch: &[PathBuf],
7727) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7728    const NAMES: [&str; 6] = [
7729        "filter",
7730        "working-tree-encoding",
7731        "ident",
7732        "text",
7733        "eol",
7734        "crlf",
7735    ];
7736    let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
7737    let mut input = String::new();
7738    for path in &paths {
7739        let path = path.to_str().ok_or_else(|| {
7740            spar_err!(
7741                "cannot inspect attributes for a non-UTF-8 path in {}",
7742                cwd.display()
7743            )
7744        })?;
7745        input.push_str(path);
7746        input.push('\0');
7747    }
7748    let argv = git_without_automation_argv(&[
7749        "check-attr",
7750        "-z",
7751        "--cached",
7752        "--stdin",
7753        "filter",
7754        "working-tree-encoding",
7755        "ident",
7756        "text",
7757        "eol",
7758        "crlf",
7759    ]);
7760    let output = proc::run_bytes(
7761        &argv,
7762        &ExecOpts::new()
7763            .cwd(cwd)
7764            .timeout_secs(30)
7765            .stdin(input)
7766            .stop_descendants(true),
7767    )?;
7768    if !output.is_empty() && !output.ends_with(&[0]) {
7769        bail!(
7770            "git returned an unterminated attribute result for {}",
7771            cwd.display()
7772        );
7773    }
7774    let fields = output
7775        .split(|byte| *byte == 0)
7776        .filter(|field| !field.is_empty())
7777        .collect::<Vec<_>>();
7778    if fields.len() != paths.len() * NAMES.len() * 3 {
7779        bail!(
7780            "git returned an unexpected attribute result for {}",
7781            cwd.display()
7782        );
7783    }
7784    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7785    for record in fields.chunks_exact(3) {
7786        let path = safe_git_path(record[0], "attribute")?;
7787        if !paths.contains(&path) {
7788            bail!(
7789                "git returned attributes for the wrong path in {}",
7790                cwd.display()
7791            );
7792        }
7793        let name = std::str::from_utf8(record[1])
7794            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
7795        let value = std::str::from_utf8(record[2])
7796            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
7797        values
7798            .entry(path)
7799            .or_default()
7800            .insert(name.to_string(), value.to_string());
7801    }
7802    if paths.iter().any(|path| {
7803        values
7804            .get(path)
7805            .is_none_or(|attributes| attributes.len() != NAMES.len())
7806    }) {
7807        bail!(
7808            "git omitted an attribute result for a tracked path in {}",
7809            cwd.display()
7810        );
7811    }
7812    Ok(values)
7813}
7814
7815fn attribute_is_active(value: Option<&String>) -> bool {
7816    !matches!(
7817        value.map(String::as_str),
7818        None | Some("unspecified") | Some("unset")
7819    )
7820}
7821
7822fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
7823    attribute_is_active(values.get("filter"))
7824        || attribute_is_active(values.get("working-tree-encoding"))
7825}
7826
7827/// The checkout settings a per-file line-ending decision depends on.
7828///
7829/// These are constant for a worktree, but the checks that read them run once
7830/// per tracked file, and each read was its own `git config` process. A
7831/// thousand-file repository with no `.gitattributes` takes every one of those
7832/// branches, which is half a minute of process spawning per scan, and the sweep
7833/// before a run scans every finished worktree more than once. Read them here,
7834/// once, and hand them down.
7835struct CheckoutConfig {
7836    autocrlf: Option<String>,
7837    eol: Option<String>,
7838    symlinks: Option<bool>,
7839}
7840
7841impl CheckoutConfig {
7842    fn read(cwd: &Path) -> Result<Self> {
7843        Ok(Self {
7844            autocrlf: git_config_value(cwd, "core.autocrlf")?,
7845            eol: git_config_value(cwd, "core.eol")?,
7846            symlinks: git_config_bool(cwd, "core.symlinks")?,
7847        })
7848    }
7849
7850    fn autocrlf_is_true(&self) -> bool {
7851        self.autocrlf.as_ref().is_some_and(|value| {
7852            matches!(
7853                value.to_ascii_lowercase().as_str(),
7854                "true" | "yes" | "on" | "1"
7855            )
7856        })
7857    }
7858
7859    fn eol_is(&self, wanted: &str) -> bool {
7860        self.eol
7861            .as_ref()
7862            .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
7863    }
7864}
7865
7866fn path_has_ambiguous_transform(
7867    config: &CheckoutConfig,
7868    values: &BTreeMap<String, String>,
7869) -> Result<bool> {
7870    if path_has_external_transform(values)
7871        || attribute_is_active(values.get("ident"))
7872        || attribute_is_active(values.get("crlf"))
7873    {
7874        return Ok(true);
7875    }
7876    let text = values.get("text").map(String::as_str);
7877    let eol = values.get("eol").map(String::as_str);
7878    if text == Some("auto") {
7879        return Ok(true);
7880    }
7881    if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
7882        || !matches!(
7883            eol,
7884            Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
7885        )
7886    {
7887        return Ok(true);
7888    }
7889    if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
7890        return Ok(config.autocrlf_is_true());
7891    }
7892    Ok(false)
7893}
7894
7895fn allows_expected_crlf(
7896    config: &CheckoutConfig,
7897    values: &BTreeMap<String, String>,
7898) -> Result<bool> {
7899    if path_has_external_transform(values)
7900        || attribute_is_active(values.get("ident"))
7901        || attribute_is_active(values.get("crlf"))
7902    {
7903        return Ok(false);
7904    }
7905    let text = values.get("text").map(String::as_str);
7906    let eol = values.get("eol").map(String::as_str);
7907    if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
7908        return Ok(false);
7909    }
7910    if eol == Some("crlf") {
7911        return Ok(true);
7912    }
7913    if text != Some("set") {
7914        return Ok(false);
7915    }
7916    if let Some(autocrlf) = config.autocrlf.as_deref() {
7917        match autocrlf.to_ascii_lowercase().as_str() {
7918            "true" | "yes" | "on" | "1" => return Ok(true),
7919            "input" => return Ok(false),
7920            _ => {}
7921        }
7922    }
7923    if config.eol_is("crlf") {
7924        return Ok(true);
7925    }
7926    #[cfg(windows)]
7927    if config.eol.is_none() || config.eol_is("native") {
7928        return Ok(true);
7929    }
7930    Ok(false)
7931}
7932
7933fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
7934    let argv = git_without_automation_argv(&["config", "--get", key]);
7935    let output = proc::exec(
7936        &argv,
7937        &ExecOpts::new()
7938            .cwd(cwd)
7939            .timeout_secs(30)
7940            .check(false)
7941            .stop_descendants(true),
7942    )?;
7943    match output.code {
7944        0 => Ok(Some(output.stdout.trim().to_string())),
7945        1 => Ok(None),
7946        _ => bail!(
7947            "could not read Git configuration in {}: {}",
7948            cwd.display(),
7949            output.stderr.trim()
7950        ),
7951    }
7952}
7953
7954fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
7955    let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
7956    let output = proc::exec(
7957        &argv,
7958        &ExecOpts::new()
7959            .cwd(cwd)
7960            .timeout_secs(30)
7961            .check(false)
7962            .stop_descendants(true),
7963    )?;
7964    match output.code {
7965        0 if output.stdout.trim() == "true" => Ok(Some(true)),
7966        0 if output.stdout.trim() == "false" => Ok(Some(false)),
7967        0 => bail!(
7968            "git returned an invalid boolean for {key} in {}",
7969            cwd.display()
7970        ),
7971        1 => Ok(None),
7972        _ => bail!(
7973            "could not read Git configuration in {}: {}",
7974            cwd.display(),
7975            output.stderr.trim()
7976        ),
7977    }
7978}
7979
7980#[cfg(unix)]
7981fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
7982    use std::os::unix::fs::PermissionsExt;
7983    if metadata.permissions().mode() & 0o111 == 0 {
7984        "100644".to_string()
7985    } else {
7986        "100755".to_string()
7987    }
7988}
7989
7990#[cfg(not(unix))]
7991fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
7992    "100644".to_string()
7993}
7994
7995fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
7996    let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
7997    if !listed.is_empty() && !listed.ends_with(&[0]) {
7998        bail!(
7999            "git returned an unterminated tree listing for {}",
8000            cwd.display()
8001        );
8002    }
8003    let mut entries = Vec::new();
8004    for record in listed
8005        .split(|byte| *byte == 0)
8006        .filter(|record| !record.is_empty())
8007    {
8008        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
8009            bail!("git returned a malformed tree record for {}", cwd.display());
8010        };
8011        let fields = record[..tab]
8012            .split(|byte| *byte == b' ')
8013            .collect::<Vec<_>>();
8014        if fields.len() != 3 {
8015            bail!("git returned a malformed tree header for {}", cwd.display());
8016        }
8017        let mode = std::str::from_utf8(fields[0])
8018            .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
8019            .to_string();
8020        let oid = std::str::from_utf8(fields[2])
8021            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
8022            .to_string();
8023        entries.push(IndexEntry {
8024            path: safe_git_path(&record[tab + 1..], "tree")?,
8025            mode,
8026            oid,
8027        });
8028    }
8029    Ok(entries)
8030}
8031
8032fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
8033    Ok(tree_entries(cwd, "HEAD")?
8034        .into_iter()
8035        .filter(|entry| entry.mode == "160000")
8036        .map(|entry| (entry.path, entry.oid))
8037        .collect())
8038}
8039
8040fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
8041    let head = head_gitlinks(cwd)?;
8042    let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
8043        .into_iter()
8044        .map(|link| (link.path, link.oid))
8045        .collect();
8046    let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
8047    paths.extend(index.keys().cloned());
8048    Ok(paths
8049        .into_iter()
8050        .filter(|path| head.get(path) != index.get(path))
8051        .collect())
8052}
8053
8054fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
8055    let path = parent.join(relative);
8056    let metadata = match std::fs::symlink_metadata(&path) {
8057        Ok(metadata) => metadata,
8058        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8059        Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
8060    };
8061    if !metadata.is_dir() {
8062        bail!("the gitlink at {} is not a directory", path.display());
8063    }
8064    let canonical = std::fs::canonicalize(&path)
8065        .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
8066    if canonical != path {
8067        bail!(
8068            "the gitlink at {} resolves through a symlink",
8069            path.display()
8070        );
8071    }
8072    if !path.join(".git").exists() {
8073        let empty = std::fs::read_dir(&path)
8074            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
8075            .next()
8076            .is_none();
8077        if empty {
8078            return Ok(None);
8079        }
8080        bail!(
8081            "the uninitialized gitlink at {} contains local files",
8082            path.display()
8083        );
8084    }
8085    let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
8086    if inside.trim() != "true" {
8087        bail!("the gitlink at {} is not a worktree", path.display());
8088    }
8089    let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
8090    let top = std::fs::canonicalize(top.trim()).map_err(|e| {
8091        spar_err!(
8092            "could not resolve the gitlink top level at {}: {e}",
8093            path.display()
8094        )
8095    })?;
8096    if top != canonical {
8097        bail!(
8098            "the gitlink at {} belongs to a different worktree",
8099            path.display()
8100        );
8101    }
8102    Ok(Some(canonical))
8103}
8104
8105/// A Git entry the outer repository cannot account for.
8106///
8107/// A checkout Git itself reports as a nested repository is expected. Its
8108/// directory is recorded by identity in the untracked snapshot and keeps the
8109/// worktree from being released, so a reviewer that clones a reproduction into
8110/// ignored build output neither loses that clone nor fails the run over it.
8111/// The scan does not descend into one, because what is under it belongs to
8112/// that repository.
8113///
8114/// A Git entry Git does not report is the one worth stopping for. Running
8115/// `git init` inside a directory that already holds tracked files leaves
8116/// nothing in any listing while making every file written under it invisible
8117/// to the outer worktree, so SPAR can no longer prove what is there.
8118fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
8119    let root = std::fs::canonicalize(cwd)
8120        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8121    let mut allowed = BTreeSet::from([root.join(".git")]);
8122    let mut checkouts = BTreeSet::new();
8123    let mut repositories = vec![root.clone()];
8124    let mut visited = BTreeSet::new();
8125    while let Some(repository) = repositories.pop() {
8126        let canonical = std::fs::canonicalize(&repository)
8127            .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8128        if !visited.insert(canonical.clone()) {
8129            bail!("submodule recursion revisited {}", canonical.display());
8130        }
8131        for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
8132            let (path, nested) = untracked_record(&raw, "untracked")?;
8133            if nested {
8134                checkouts.insert(canonical.join(path));
8135            }
8136        }
8137        for link in gitlinks(&canonical)? {
8138            let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
8139                continue;
8140            };
8141            allowed.insert(submodule.join(".git"));
8142            repositories.push(submodule);
8143        }
8144    }
8145
8146    // A build deletes its own directories while this walks them. A directory
8147    // that is gone holds no Git entry, so skipping it answers the question
8148    // being asked; erroring instead ended runs whose worktrees were fine.
8149    let scan_root = root.clone();
8150    let mut directories = vec![root];
8151    while let Some(directory) = directories.pop() {
8152        let entries = match std::fs::read_dir(&directory) {
8153            Ok(entries) => entries,
8154            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8155            Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8156        };
8157        for entry in entries {
8158            let entry = match entry {
8159                Ok(entry) => entry,
8160                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8161                Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8162            };
8163            let path = entry.path();
8164            if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
8165                continue;
8166            }
8167            if entry.file_name() == OsStr::new(".git") {
8168                if !allowed.contains(&path) {
8169                    return Ok(Some(path));
8170                }
8171                continue;
8172            }
8173            let kind = match entry.file_type() {
8174                Ok(kind) => kind,
8175                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8176                Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
8177            };
8178            if kind.is_dir() && !checkouts.contains(&path) {
8179                directories.push(path);
8180            }
8181        }
8182    }
8183    Ok(None)
8184}
8185
8186pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
8187    if let Some(path) = unexpected_nested_git_entry(cwd)? {
8188        bail!(
8189            "the worktree contains a Git entry at {} that Git does not report as a nested \
8190             checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
8191             prove they are unchanged.",
8192            path.display()
8193        );
8194    }
8195    let root = std::fs::canonicalize(cwd)
8196        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8197    let mut repositories = BTreeMap::new();
8198    let mut visited = BTreeSet::new();
8199    collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
8200    Ok(GitState { repositories })
8201}
8202
8203fn collect_git_state(
8204    repository: &Path,
8205    prefix: &Path,
8206    visited: &mut BTreeSet<PathBuf>,
8207    repositories: &mut BTreeMap<PathBuf, RepositoryState>,
8208) -> Result<()> {
8209    let canonical = std::fs::canonicalize(repository)
8210        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8211    if !visited.insert(canonical.clone()) {
8212        bail!("submodule recursion revisited {}", canonical.display());
8213    }
8214    let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8215    let head = head.trim().to_string();
8216    if head.is_empty() {
8217        bail!("git returned an empty head for {}", repository.display());
8218    }
8219    let unsafe_index_flags = unsafe_index_flags(repository)?;
8220    let tracked = tracked_entries(repository)?;
8221    let gitlinks = gitlinks(repository)?;
8222    if repositories
8223        .insert(
8224            prefix.to_path_buf(),
8225            RepositoryState {
8226                head,
8227                unsafe_index_flags,
8228                tracked,
8229                gitlinks: gitlinks
8230                    .iter()
8231                    .map(|link| (link.path.clone(), link.oid.clone()))
8232                    .collect(),
8233            },
8234        )
8235        .is_some()
8236    {
8237        bail!("Git state contains duplicate repository path {:?}", prefix);
8238    }
8239
8240    for link in gitlinks {
8241        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
8242            continue;
8243        };
8244        collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
8245    }
8246    Ok(())
8247}
8248
8249fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
8250    let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
8251    if !listed.is_empty() && !listed.ends_with(&[0]) {
8252        bail!(
8253            "git returned an unterminated index-flag listing for {}",
8254            cwd.display()
8255        );
8256    }
8257    let mut unsafe_records = Vec::new();
8258    for record in listed
8259        .split(|byte| *byte == 0)
8260        .filter(|record| !record.is_empty())
8261    {
8262        if record.len() < 3 || record[1] != b' ' {
8263            bail!(
8264                "git returned a malformed index-flag record for {}",
8265                cwd.display()
8266            );
8267        }
8268        if record[0] != b'H' {
8269            unsafe_records.extend_from_slice(record);
8270            unsafe_records.push(0);
8271        }
8272    }
8273    Ok(unsafe_records)
8274}
8275
8276pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
8277    safe_git_state(cwd).map(|_| ())
8278}
8279
8280pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
8281    let state = git_state(cwd)?;
8282    if let Some((path, _repository)) = state
8283        .repositories
8284        .iter()
8285        .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
8286    {
8287        let label = if path.as_os_str().is_empty() {
8288            cwd.to_path_buf()
8289        } else {
8290            cwd.join(path)
8291        };
8292        bail!(
8293            "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
8294             SPAR cannot prove the working files are unchanged, so it was kept.",
8295            label.display()
8296        );
8297    }
8298    Ok(state)
8299}
8300
8301fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
8302    repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
8303}
8304
8305fn repository_has_recoverable_work_with(
8306    cwd: &Path,
8307    include_ignored: bool,
8308    disposable: Disposable,
8309) -> Result<bool> {
8310    if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
8311        return Ok(true);
8312    }
8313    let mut visited = BTreeSet::new();
8314    repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
8315}
8316
8317fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
8318    let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
8319    let git_dir = PathBuf::from(git_dir.trim());
8320    let git_dir = if git_dir.is_absolute() {
8321        git_dir
8322    } else {
8323        cwd.join(git_dir)
8324    };
8325    let git_dir = std::fs::canonicalize(&git_dir)
8326        .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
8327    match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
8328        Ok(_) => return Ok(true),
8329        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8330        Err(error) => {
8331            return Err(spar_err!(
8332                "could not inspect per-worktree configuration in {}: {error}",
8333                git_dir.display()
8334            ))
8335        }
8336    }
8337
8338    let orig_head = git_dir.join("ORIG_HEAD");
8339    match std::fs::symlink_metadata(&orig_head) {
8340        Ok(metadata) if metadata.is_file() => {
8341            let oid = std::fs::read_to_string(&orig_head)
8342                .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
8343            let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
8344                return Ok(true);
8345            };
8346            if !commit_has_shared_ref(cwd, &commit)? {
8347                return Ok(true);
8348            }
8349        }
8350        Ok(_) => return Ok(true),
8351        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8352        Err(error) => {
8353            return Err(spar_err!(
8354                "could not inspect {}: {error}",
8355                orig_head.display()
8356            ))
8357        }
8358    }
8359
8360    let edit_message = git_dir.join("COMMIT_EDITMSG");
8361    match std::fs::symlink_metadata(&edit_message) {
8362        Ok(metadata) if metadata.is_file() => {
8363            let draft = std::fs::read(&edit_message)
8364                .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
8365            if draft != head_commit_message(cwd)? {
8366                return Ok(true);
8367            }
8368        }
8369        Ok(_) => return Ok(true),
8370        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8371        Err(error) => {
8372            return Err(spar_err!(
8373                "could not inspect {}: {error}",
8374                edit_message.display()
8375            ))
8376        }
8377    }
8378
8379    if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
8380        return Ok(true);
8381    }
8382
8383    let local_refs = run_git_bytes(
8384        cwd,
8385        &[
8386            "for-each-ref",
8387            "--format=%(refname)",
8388            "refs/worktree",
8389            "refs/bisect",
8390            "refs/rewritten",
8391        ],
8392    )?;
8393    if !local_refs.is_empty() {
8394        return Ok(true);
8395    }
8396
8397    // `FETCH_HEAD` only names what a `git fetch` in this worktree brought
8398    // down. The objects live in the shared store and the next fetch rewrites
8399    // it, so an agent that fetched once must not keep the checkout forever.
8400    for entry in std::fs::read_dir(&git_dir)
8401        .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
8402    {
8403        let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
8404        let known = matches!(
8405            entry.file_name().to_str(),
8406            Some(
8407                "HEAD"
8408                    | "ORIG_HEAD"
8409                    | "COMMIT_EDITMSG"
8410                    | "FETCH_HEAD"
8411                    | "commondir"
8412                    | "gitdir"
8413                    | "index"
8414                    | "logs"
8415                    | "refs"
8416            )
8417        );
8418        if !known {
8419            return Ok(true);
8420        }
8421    }
8422
8423    let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8424    if !commit_has_shared_ref(cwd, head.trim())? {
8425        return Ok(true);
8426    }
8427    Ok(false)
8428}
8429
8430fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
8431    let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
8432    let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
8433        bail!(
8434            "git returned a commit without a message separator in {}",
8435            cwd.display()
8436        );
8437    };
8438    Ok(commit[split + 2..].to_vec())
8439}
8440
8441fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
8442    let metadata = match std::fs::symlink_metadata(logs) {
8443        Ok(metadata) => metadata,
8444        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
8445        Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
8446    };
8447    if !metadata.is_dir() {
8448        return Ok(true);
8449    }
8450    let mut files = Vec::new();
8451    let mut directories = vec![logs.to_path_buf()];
8452    while let Some(directory) = directories.pop() {
8453        for entry in std::fs::read_dir(&directory)
8454            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
8455        {
8456            let entry =
8457                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
8458            let path = entry.path();
8459            let kind = entry
8460                .file_type()
8461                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
8462            if kind.is_dir() {
8463                directories.push(path);
8464            } else if kind.is_file() {
8465                files.push(path);
8466            } else {
8467                return Ok(true);
8468            }
8469        }
8470    }
8471
8472    let mut commits = BTreeSet::new();
8473    for path in files {
8474        if !collect_reflog_commits(cwd, &path, &mut commits)? {
8475            return Ok(true);
8476        }
8477    }
8478    for commit in commits {
8479        if !commit_has_shared_ref(cwd, &commit)? {
8480            return Ok(true);
8481        }
8482    }
8483    Ok(false)
8484}
8485
8486/// Every commit named by one ref's common reflog must survive deletion of that
8487/// ref. Ancestors of a durable current tip survive with the tip; divergent
8488/// entries need another shared ref of their own.
8489fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
8490    let common = common_git_dir(cwd)?;
8491    let reflog = common.join("logs").join(refname);
8492    let metadata = match std::fs::symlink_metadata(&reflog) {
8493        Ok(metadata) => metadata,
8494        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
8495        Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
8496    };
8497    if !metadata.is_file() {
8498        return Ok(false);
8499    }
8500    let mut commits = BTreeSet::new();
8501    if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
8502        return Ok(false);
8503    }
8504    for commit in commits {
8505        if is_ancestor(cwd, &commit, durable_tip)?
8506            || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
8507        {
8508            continue;
8509        }
8510        return Ok(false);
8511    }
8512    Ok(true)
8513}
8514
8515fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
8516    let file = std::fs::File::open(path)
8517        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8518    for line in std::io::BufReader::new(file).lines() {
8519        let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8520        let mut fields = line.splitn(3, ' ');
8521        let Some(old) = fields.next() else {
8522            return Ok(false);
8523        };
8524        let Some(new) = fields.next() else {
8525            return Ok(false);
8526        };
8527        if fields.next().is_none() {
8528            return Ok(false);
8529        }
8530        for oid in [old, new] {
8531            if oid.bytes().all(|byte| byte == b'0') {
8532                continue;
8533            }
8534            let Some(commit) = resolve_optional_commit(cwd, oid)? else {
8535                return Ok(false);
8536            };
8537            commits.insert(commit);
8538        }
8539    }
8540    Ok(true)
8541}
8542
8543fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
8544    let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
8545    let path = PathBuf::from(raw.trim());
8546    let path = if path.is_absolute() {
8547        path
8548    } else {
8549        cwd.join(path)
8550    };
8551    std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
8552}
8553
8554fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
8555    let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
8556    let output = proc::exec(
8557        &argv,
8558        &ExecOpts::new()
8559            .cwd(cwd)
8560            .timeout_secs(30)
8561            .check(false)
8562            .stop_descendants(true),
8563    )?;
8564    match output.code {
8565        0 => Ok(true),
8566        1 => Ok(false),
8567        _ => bail!("{}", proc::failure_message(&argv, &output)),
8568    }
8569}
8570
8571fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
8572    let commit = format!("{oid}^{{commit}}");
8573    let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
8574    let output = proc::exec(
8575        &argv,
8576        &ExecOpts::new()
8577            .cwd(cwd)
8578            .timeout_secs(30)
8579            .check(false)
8580            .stop_descendants(true),
8581    )?;
8582    if output.code != 0 {
8583        return Ok(None);
8584    }
8585    let oid = output.stdout.trim();
8586    if oid.is_empty() {
8587        return Ok(None);
8588    }
8589    Ok(Some(oid.to_string()))
8590}
8591
8592fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
8593    commit_has_shared_ref_except(cwd, oid, None)
8594}
8595
8596fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
8597    let contains = format!("--contains={oid}");
8598    let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
8599    Ok(shared.split(|byte| *byte == b'\n').any(|record| {
8600        !record.is_empty()
8601            && !record.starts_with(b"refs/worktree/")
8602            && !record.starts_with(b"refs/bisect/")
8603            && !record.starts_with(b"refs/rewritten/")
8604            && exclude.is_none_or(|excluded| record != excluded.as_bytes())
8605    }))
8606}
8607
8608/// Whether removing the worktree would take away an untracked file somebody
8609/// might want back.
8610///
8611/// Ordinary untracked files always count. So does an ignored file outside the
8612/// known build and cache directories, because an ignored path is only a path
8613/// Git was told not to track, which is where a local `.env` lives as readily as
8614/// compiler output.
8615///
8616/// Recognized build and cache output does not. A managed commit already leaves
8617/// it out rather than treating it as work, and the command that wrote it writes
8618/// it again. Counting it kept every worktree whose tests or build had run,
8619/// which is nearly all of them, so a merged pull request still left its
8620/// checkout behind. A repository nested in that output is somebody else's
8621/// history and counts whatever it sits under.
8622fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
8623    let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
8624    for raw in &ordinary {
8625        let (path, _) = untracked_record(raw, "untracked")?;
8626        if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
8627            continue;
8628        }
8629        return Ok(true);
8630    }
8631    let listed = untracked_listing(
8632        cwd,
8633        &[
8634            "ls-files",
8635            "--others",
8636            "--ignored",
8637            "--exclude-standard",
8638            "-z",
8639        ],
8640    )?;
8641    for raw in listed {
8642        let (path, nested) = untracked_record(&raw, "ignored")?;
8643        // A repository cloned into the output keeps the checkout whatever the
8644        // mode: its objects exist nowhere else, which is the one thing a
8645        // rebuild from origin cannot replace.
8646        if nested {
8647            return Ok(true);
8648        }
8649        if disposable == Disposable::BuildOutput
8650            && !is_generated_artifact(&path)
8651            && !is_desktop_metadata(&path)
8652        {
8653            return Ok(true);
8654        }
8655    }
8656    Ok(false)
8657}
8658
8659fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8660    let listed = run_git_bytes(cwd, args)?;
8661    if !listed.is_empty() && !listed.ends_with(&[0]) {
8662        bail!(
8663            "git returned an unterminated untracked-file list for {}",
8664            cwd.display()
8665        );
8666    }
8667    Ok(listed
8668        .split(|byte| *byte == 0)
8669        .filter(|raw| !raw.is_empty())
8670        .map(|raw| raw.to_vec())
8671        .collect())
8672}
8673
8674fn repository_has_recoverable_work_inner(
8675    cwd: &Path,
8676    include_ignored: bool,
8677    disposable: Disposable,
8678    visited: &mut BTreeSet<PathBuf>,
8679) -> Result<bool> {
8680    let canonical = std::fs::canonicalize(cwd)
8681        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8682    if !visited.insert(canonical.clone()) {
8683        bail!("submodule recursion revisited {}", canonical.display());
8684    }
8685    if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
8686        return Ok(true);
8687    }
8688    if !unsafe_index_flags(cwd)?.is_empty() {
8689        return Ok(true);
8690    }
8691    if attributes_may_be_modified(cwd)? {
8692        return Ok(true);
8693    }
8694    if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
8695        return Ok(true);
8696    }
8697    if include_ignored {
8698        let index = index_entries(cwd)?
8699            .into_iter()
8700            .map(|entry| (entry.path, (entry.mode, entry.oid)))
8701            .collect::<BTreeMap<_, _>>();
8702        let head = tree_entries(cwd, "HEAD")?
8703            .into_iter()
8704            .map(|entry| (entry.path, (entry.mode, entry.oid)))
8705            .collect::<BTreeMap<_, _>>();
8706        if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
8707            return Ok(true);
8708        }
8709        let tracked = tracked_entries(cwd)?;
8710        let effective = check_attributes(cwd, tracked.keys().cloned())?;
8711        let config = CheckoutConfig::read(cwd)?;
8712        for (path, entry) in tracked {
8713            let Some(worktree) = entry.worktree else {
8714                return Ok(true);
8715            };
8716            let attributes = effective.get(&path).ok_or_else(|| {
8717                spar_err!("git omitted attributes for {}", cwd.join(&path).display())
8718            })?;
8719            if path_has_ambiguous_transform(&config, attributes)? {
8720                return Ok(true);
8721            }
8722            let symlink_file = entry.index_mode == "120000"
8723                && worktree.mode == "100644"
8724                && worktree.raw_oid == entry.index_oid
8725                && config.symlinks == Some(false);
8726            if worktree.mode != entry.index_mode && !symlink_file {
8727                return Ok(true);
8728            }
8729            if entry.index_mode == "120000" {
8730                if worktree.raw_oid != entry.index_oid {
8731                    return Ok(true);
8732                }
8733                continue;
8734            }
8735            #[cfg(unix)]
8736            {
8737                let expected = if entry.index_mode == "100755" {
8738                    0o755
8739                } else {
8740                    0o644
8741                };
8742                if worktree.permissions != expected {
8743                    return Ok(true);
8744                }
8745            }
8746            if allows_expected_crlf(&config, attributes)? {
8747                let (normalized, every_lf_was_crlf) =
8748                    normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
8749                if !every_lf_was_crlf || normalized != entry.index_oid {
8750                    return Ok(true);
8751                }
8752            } else if worktree.raw_oid != entry.index_oid {
8753                return Ok(true);
8754            }
8755        }
8756    } else {
8757        let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
8758        if !run_git_bytes(cwd, &args)?.is_empty() {
8759            return Ok(true);
8760        }
8761    }
8762    for link in gitlinks(cwd)? {
8763        let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
8764            continue;
8765        };
8766        // Git stores a linked worktree's initialized submodule objects under
8767        // that worktree's administrative directory. Ordinary removal cannot
8768        // prove a local submodule commit exists anywhere else, even when both
8769        // working trees look clean.
8770        if include_ignored {
8771            return Ok(true);
8772        }
8773        let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8774        if head.trim() != link.oid {
8775            return Ok(true);
8776        }
8777        if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
8778        {
8779            return Ok(true);
8780        }
8781    }
8782    Ok(false)
8783}
8784
8785pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
8786    repository_has_recoverable_work(cwd, false)
8787}
8788
8789fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
8790    let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
8791    Ok(!run_git_bytes(cwd, &args)?.is_empty())
8792}
8793
8794#[cfg(unix)]
8795fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8796    use std::os::unix::ffi::OsStringExt;
8797    Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
8798}
8799
8800#[cfg(not(unix))]
8801fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8802    String::from_utf8(raw.to_vec())
8803        .map(PathBuf::from)
8804        .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
8805}
8806
8807/// Fingerprint the directory of a nested repository without reading inside it.
8808///
8809/// The files under it are that repository's, not this one's. They are recorded
8810/// against its own baseline whenever SPAR works there, and a run of its own may
8811/// legitimately add or remove entries while this call is in flight, so the
8812/// volatile directory fields stay out of the fingerprint. Identity and type
8813/// remain, which is what makes deleting the checkout, or replacing it with a
8814/// file, observable from the outer worktree.
8815/// What a path Git listed looks like once it is no longer there.
8816///
8817/// A reviewer building the project deletes and rewrites its output constantly,
8818/// so a path can go between the listing that named it and the stat that
8819/// measures it. Treating that as an unreadable worktree ended runs that had
8820/// nothing wrong with them. The record stays in the snapshot rather than being
8821/// dropped, so a path that vanishes cannot pass for one that was never there.
8822fn vanished_untracked_file() -> UntrackedFile {
8823    UntrackedFile {
8824        kind: 0,
8825        len: 0,
8826        modified: None,
8827        created: None,
8828        readonly: false,
8829        symlink_target: None,
8830        #[cfg(unix)]
8831        device: 0,
8832        #[cfg(unix)]
8833        inode: 0,
8834        #[cfg(unix)]
8835        mode: 0,
8836        #[cfg(unix)]
8837        change_seconds: 0,
8838        #[cfg(unix)]
8839        change_nanoseconds: 0,
8840    }
8841}
8842
8843fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
8844    let metadata = match std::fs::symlink_metadata(path) {
8845        Ok(metadata) => metadata,
8846        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8847        Err(e) => {
8848            return Err(spar_err!(
8849                "could not inspect the nested repository at {}: {e}",
8850                path.display()
8851            ))
8852        }
8853    };
8854    if !metadata.is_dir() {
8855        bail!(
8856            "git reported {} as a nested repository, but it is not a directory",
8857            path.display()
8858        );
8859    }
8860    if std::fs::symlink_metadata(path.join(".git")).is_err() {
8861        bail!(
8862            "git reported {} as a nested repository, but it has no Git entry",
8863            path.display()
8864        );
8865    }
8866    #[cfg(unix)]
8867    {
8868        use std::os::unix::fs::MetadataExt;
8869        Ok(UntrackedFile {
8870            kind: 3,
8871            len: 0,
8872            modified: None,
8873            created: metadata.created().ok(),
8874            readonly: metadata.permissions().readonly(),
8875            symlink_target: None,
8876            device: metadata.dev(),
8877            inode: metadata.ino(),
8878            mode: metadata.mode(),
8879            change_seconds: 0,
8880            change_nanoseconds: 0,
8881        })
8882    }
8883    #[cfg(not(unix))]
8884    {
8885        Ok(UntrackedFile {
8886            kind: 3,
8887            len: 0,
8888            modified: None,
8889            created: metadata.created().ok(),
8890            readonly: metadata.permissions().readonly(),
8891            symlink_target: None,
8892        })
8893    }
8894}
8895
8896fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
8897    let metadata = match std::fs::symlink_metadata(path) {
8898        Ok(metadata) => metadata,
8899        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8900        Err(e) => {
8901            return Err(spar_err!(
8902                "could not inspect untracked file {}: {e}",
8903                path.display()
8904            ))
8905        }
8906    };
8907    let kind = if metadata.file_type().is_symlink() {
8908        2
8909    } else if metadata.is_file() {
8910        1
8911    } else {
8912        bail!(
8913            "untracked path {} is not a regular file or symlink",
8914            path.display()
8915        );
8916    };
8917    let symlink_target = if kind == 2 {
8918        match std::fs::read_link(path) {
8919            Ok(target) => Some(os_str_bytes(target.as_os_str())?),
8920            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
8921                return Ok(vanished_untracked_file())
8922            }
8923            Err(e) => {
8924                return Err(spar_err!(
8925                    "could not read untracked symlink {}: {e}",
8926                    path.display()
8927                ))
8928            }
8929        }
8930    } else {
8931        None
8932    };
8933    #[cfg(unix)]
8934    {
8935        use std::os::unix::fs::MetadataExt;
8936        Ok(UntrackedFile {
8937            kind,
8938            len: metadata.len(),
8939            modified: metadata.modified().ok(),
8940            created: metadata.created().ok(),
8941            readonly: metadata.permissions().readonly(),
8942            symlink_target,
8943            device: metadata.dev(),
8944            inode: metadata.ino(),
8945            mode: metadata.mode(),
8946            change_seconds: metadata.ctime(),
8947            change_nanoseconds: metadata.ctime_nsec(),
8948        })
8949    }
8950    #[cfg(not(unix))]
8951    {
8952        Ok(UntrackedFile {
8953            kind,
8954            len: metadata.len(),
8955            modified: metadata.modified().ok(),
8956            created: metadata.created().ok(),
8957            readonly: metadata.permissions().readonly(),
8958            symlink_target,
8959        })
8960    }
8961}
8962
8963#[cfg(unix)]
8964fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8965    use std::os::unix::ffi::OsStrExt;
8966    Ok(value.as_bytes().to_vec())
8967}
8968
8969#[cfg(not(unix))]
8970fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8971    value
8972        .to_str()
8973        .map(|value| value.as_bytes().to_vec())
8974        .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
8975}
8976
8977#[cfg(unix)]
8978fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8979    use std::os::unix::fs::MetadataExt;
8980    right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
8981}
8982
8983#[cfg(not(unix))]
8984fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8985    right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
8986}
8987
8988#[derive(Debug, Clone, serde::Serialize, Deserialize)]
8989pub struct BranchRecord {
8990    pub kind: String,
8991    pub number: i64,
8992}
8993
8994/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
8995/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
8996pub fn review_ref(number: i64) -> String {
8997    format!("refs/spar/pr-{number}")
8998}
8999
9000pub fn is_finished(state: &str) -> bool {
9001    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
9002}
9003
9004/// Write text through a temporary file and rename, so a kill cannot leave a
9005/// truncated file behind.
9006///
9007/// The follow-up queue is the one file spar rewrites in place rather than
9008/// appends to, and a truncated queue is lost work: what it held was never
9009/// written anywhere else.
9010pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
9011    if let Some(parent) = path.parent() {
9012        std::fs::create_dir_all(parent)
9013            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
9014    }
9015    // The extension defaults to `json` so `clear_state`, which removes a
9016    // leftover `pr-N.json.tmp` by name, keeps finding the one this wrote.
9017    let tmp = path.with_extension(format!(
9018        "{}.tmp",
9019        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
9020    ));
9021    std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
9022    std::fs::rename(&tmp, path)
9023        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
9024    Ok(())
9025}
9026
9027/// Whether a follow-up file already has an entry under this exact title.
9028///
9029/// Whole heading lines, case insensitively. A substring check answered yes
9030/// for "Retry loop" against "## Retry loop never terminates", which is a
9031/// different defect, and the new one was dropped with a line saying it was
9032/// already noted.
9033pub fn has_heading(text: &str, title: &str) -> bool {
9034    let wanted = title.trim().to_lowercase();
9035    if wanted.is_empty() {
9036        return false;
9037    }
9038    text.lines()
9039        .filter_map(|line| line.trim().strip_prefix("## "))
9040        .any(|heading| heading.trim().to_lowercase() == wanted)
9041}
9042
9043/// Write JSON through a temporary file and rename, so a kill cannot leave a
9044/// truncated state file behind.
9045pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
9046    write_text_atomic(path, &serde_json::to_string_pretty(value)?)
9047}
9048
9049/// One row of `gh pr list --json number,url,title,baseRefName,closingIssuesReferences`.
9050#[derive(Deserialize)]
9051#[serde(rename_all = "camelCase")]
9052pub struct LinkedPrRow {
9053    number: i64,
9054    #[serde(default)]
9055    url: String,
9056    #[serde(default)]
9057    title: String,
9058    #[serde(default)]
9059    closing_issues_references: Vec<IssueRef>,
9060}
9061
9062/// The listed pull requests, or `None` when the payload could not be read.
9063///
9064/// Separated from the gh call so the real payload shape can be tested, and kept
9065/// distinct from an empty list, because silently failing to parse would look
9066/// exactly like "no pull request exists", which is the answer that makes spar
9067/// implement over the top of somebody's work.
9068pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
9069    serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
9070}
9071
9072/// Among the listed pull requests, the first that would close `issue`.
9073pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
9074    rows.iter()
9075        .find(|row| {
9076            row.closing_issues_references
9077                .iter()
9078                .any(|linked| linked.number == issue)
9079        })
9080        .map(|row| PrRef {
9081            number: row.number,
9082            url: row.url.clone(),
9083            title: row.title.clone(),
9084        })
9085}
9086
9087/// Both halves together, for the tests that hold the real payload.
9088pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
9089    pick_linked_pr(&linked_pr_rows(json)?, issue)
9090}
9091
9092/// Flatten whatever `gh api --paginate` printed into a list of comments.
9093///
9094/// Current gh merges array pages into one array. Older builds concatenated one
9095/// document per page. A streaming parser reads either, and unlike splitting the
9096/// text on a bracket pair it cannot be fooled by a comment body that happens to
9097/// contain one, which would otherwise make a resume silently start over.
9098pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
9099    if text.trim().is_empty() {
9100        return Err(spar_err!("GitHub returned no comment data"));
9101    }
9102    let mut out = Vec::new();
9103    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
9104        match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
9105            Value::Array(items) => out.extend(items),
9106            _ => return Err(spar_err!("unexpected non-array comment page")),
9107        }
9108    }
9109    Ok(out)
9110}
9111
9112pub fn parse_comment_pages(text: &str) -> Vec<Value> {
9113    let mut out = Vec::new();
9114    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
9115        match value {
9116            Ok(Value::Array(items)) => out.extend(items),
9117            Ok(other) => out.push(other),
9118            Err(_) => break,
9119        }
9120    }
9121    out
9122}
9123
9124/// Whether a comment is a state block spar itself wrote.
9125///
9126/// The body has to open with the marker, so a person quoting it in a sentence
9127/// is not one; it has to parse, so a half-quoted block is not one; and it has
9128/// to be the viewer's own comment, the way `comments.rs` already tells spar's
9129/// writes apart. Anything less and `write_pr_state` patches somebody's comment
9130/// with JSON, and `spar clean --pr-state` later deletes it.
9131pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
9132    body.trim_start().starts_with(STATE_MARKER)
9133        && !viewer.trim().is_empty()
9134        && author.trim().eq_ignore_ascii_case(viewer.trim())
9135        && parse_state_comment(body).is_some()
9136}
9137
9138/// Extract the payload from a state comment. The marker is followed by JSON and
9139/// terminated with `-->`.
9140pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
9141    let marker = body.find(STATE_MARKER)?;
9142    let start = body[marker..].find('{')? + marker;
9143    let end = body.rfind('}')?;
9144    if end <= start {
9145        return None;
9146    }
9147    match serde_json::from_str(&body[start..=end]) {
9148        Ok(state) => Some(state),
9149        Err(_) => {
9150            logdim!("found a spar state comment but could not parse it");
9151            None
9152        }
9153    }
9154}
9155
9156fn choose_state_for_head(
9157    candidates: Vec<PersistedState>,
9158    actual_head: &str,
9159) -> Option<PersistedState> {
9160    let matching: Vec<PersistedState> = candidates
9161        .iter()
9162        .filter(|state| state.pr_head == actual_head)
9163        .cloned()
9164        .collect();
9165    if !matching.is_empty() {
9166        return newest_state(matching);
9167    }
9168    newest_state(candidates)
9169}
9170
9171fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
9172    candidates.into_iter().reduce(|best, candidate| {
9173        if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
9174            candidate
9175        } else {
9176            // The local candidate is supplied first. Keeping the first exact
9177            // tie recovers correctly from a local write followed by a failed
9178            // pull request state update, including legacy states with no
9179            // checkpoint field.
9180            best
9181        }
9182    })
9183}
9184
9185/// Where this binary lives, so `git filter-branch` can call back into it.
9186///
9187/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
9188/// tests, whose `current_exe` is the test harness rather than spar, and for
9189/// anyone who ships spar behind a wrapper script.
9190pub fn self_binary() -> Result<PathBuf> {
9191    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
9192        let path = PathBuf::from(path);
9193        if proc::is_executable(&path) {
9194            return Ok(path);
9195        }
9196        bail!(
9197            "SPAR_SELF_BIN is set to {}, which is not executable",
9198            path.display()
9199        );
9200    }
9201    std::env::current_exe()
9202        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
9203}
9204
9205fn bool_env(value: bool) -> &'static str {
9206    if value {
9207        "1"
9208    } else {
9209        "0"
9210    }
9211}
9212
9213/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
9214/// shell command, and an install path with a space in it is not exotic.
9215pub fn sh_quote(text: &str) -> String {
9216    format!("'{}'", text.replace('\'', r"'\''"))
9217}
9218
9219/// Style rules for the `scrub-filter` subcommand, which runs in a child process
9220/// spawned by git and so cannot see the parent's config.
9221pub fn style_from_env() -> Style {
9222    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
9223    Style {
9224        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
9225        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
9226        ..Style::permissive()
9227    }
9228}
9229
9230#[cfg(test)]
9231mod tests {
9232    use super::*;
9233    use crate::config::StateStore;
9234    use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
9235    use std::process::Command;
9236
9237    fn repo_for_titles() -> Repo {
9238        Repo {
9239            root: PathBuf::from("/nonexistent"),
9240            style: Style::default(),
9241            branch_prefix: String::new(),
9242            state_store: StateStore::Local,
9243            followups: crate::config::Followups::Issues,
9244            drafts: Drafts::Never,
9245            fork_when_denied: true,
9246            setup: CheckCommand::default(),
9247            isolate_reads: true,
9248            keep_worktrees: false,
9249            fork: OnceLock::new(),
9250            viewer: OnceLock::new(),
9251            checkpoints: Mutex::new(BTreeMap::new()),
9252            writes: WriteStats::default(),
9253        }
9254    }
9255
9256    /// The sweep deletes on what this says, so a name it does not recognise
9257    /// is no read checkout at all.
9258    #[test]
9259    fn only_a_read_checkout_name_names_its_owner() {
9260        assert_eq!(Some(4242), read_checkout_owner("read-4242-0"));
9261        assert_eq!(None, read_checkout_owner("read-4242"));
9262        assert_eq!(None, read_checkout_owner("read-me-0"));
9263        assert_eq!(None, read_checkout_owner("read-4242-x"));
9264        assert_eq!(None, read_checkout_owner("review-4242"));
9265    }
9266
9267    #[test]
9268    fn write_results_accumulate_for_the_run() {
9269        let repo = repo_for_titles();
9270
9271        let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
9272        let _: std::result::Result<(), ()> = repo.record_write(Err(()));
9273
9274        assert_eq!(
9275            WriteSummary {
9276                attempted: 2,
9277                failed: 1,
9278            },
9279            repo.write_summary()
9280        );
9281    }
9282
9283    #[test]
9284    fn only_failed_write_preflights_join_the_summary() {
9285        let repo = repo_for_titles();
9286
9287        let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
9288        let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
9289
9290        assert_eq!(
9291            WriteSummary {
9292                attempted: 1,
9293                failed: 1,
9294            },
9295            repo.write_summary()
9296        );
9297    }
9298
9299    #[test]
9300    fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
9301        let repo = repo_for_titles();
9302
9303        assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
9304        assert_eq!(
9305            WriteSummary {
9306                attempted: 1,
9307                failed: 1,
9308            },
9309            repo.write_summary()
9310        );
9311    }
9312
9313    #[test]
9314    fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
9315        let mut repo = repo_for_titles();
9316        repo.followups = Followups::Local;
9317
9318        assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
9319        assert_eq!(WriteSummary::default(), repo.write_summary());
9320    }
9321
9322    #[test]
9323    fn a_failed_remote_state_read_stops_before_state_mutation() {
9324        let root = std::env::temp_dir().join(format!(
9325            "spar-state-preflight-{}-{}",
9326            std::process::id(),
9327            std::time::SystemTime::now()
9328                .duration_since(std::time::UNIX_EPOCH)
9329                .unwrap()
9330                .as_nanos()
9331        ));
9332        std::fs::create_dir_all(&root).unwrap();
9333        let _fixture = ReviewFixture { root: root.clone() };
9334        let mut repo = repo_for_titles();
9335        repo.root = root;
9336        repo.state_store = StateStore::Both;
9337        let state = PersistedState {
9338            version: 1,
9339            checkpoint: 4,
9340            round: 2,
9341            next_actor: "a".into(),
9342            status: Status::Pending,
9343            pr_head: "abc123".into(),
9344            ledger: Ledger::new(),
9345            filed: Vec::new(),
9346            open_findings: Vec::new(),
9347            disputes: Vec::new(),
9348            noted: Vec::new(),
9349        };
9350
9351        let error = repo
9352            .write_state_after_remote_read(
9353                7,
9354                &state,
9355                Err(crate::error::SparError::new("state comments unavailable")),
9356            )
9357            .unwrap_err();
9358
9359        assert!(error.to_string().contains("state comments unavailable"));
9360        assert!(!repo.state_path(7).exists());
9361        assert_eq!(0, repo.remembered_checkpoint(7));
9362        assert_eq!(
9363            WriteSummary {
9364                attempted: 1,
9365                failed: 1,
9366            },
9367            repo.write_summary()
9368        );
9369    }
9370
9371    #[test]
9372    fn only_known_build_and_cache_directories_are_generated_artifacts() {
9373        assert!(is_generated_artifact(Path::new("target/debug/artifact")));
9374        assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
9375        assert!(is_generated_artifact(Path::new(
9376            "package/node_modules/dependency/file.js"
9377        )));
9378        assert!(!is_generated_artifact(Path::new(
9379            "distribution/required-package.js"
9380        )));
9381        assert!(!is_generated_artifact(Path::new(
9382            "generated/required-fixture.txt"
9383        )));
9384        assert!(!is_generated_artifact(Path::new("local.env")));
9385    }
9386
9387    #[test]
9388    fn only_finder_state_files_are_desktop_metadata() {
9389        assert!(is_desktop_metadata(Path::new(".DS_Store")));
9390        assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
9391        assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
9392        assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
9393    }
9394
9395    struct ReviewFixture {
9396        root: PathBuf,
9397    }
9398
9399    impl Drop for ReviewFixture {
9400        fn drop(&mut self) {
9401            let _ = std::fs::remove_dir_all(&self.root);
9402        }
9403    }
9404
9405    fn test_git(cwd: &Path, args: &[&str]) -> String {
9406        let output = Command::new("git")
9407            .args(args)
9408            .current_dir(cwd)
9409            .output()
9410            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
9411        assert!(
9412            output.status.success(),
9413            "git {args:?} failed: {}",
9414            String::from_utf8_lossy(&output.stderr)
9415        );
9416        String::from_utf8_lossy(&output.stdout).into_owned()
9417    }
9418
9419    /// The diff a line comment is checked against is parsed from git's output,
9420    /// so a user setting that changes that output must not change the answer,
9421    /// and a text converter the branch could have changed must never run.
9422    #[test]
9423    fn the_lines_a_comment_can_go_on_ignore_how_the_user_has_git_print_a_diff() {
9424        let root =
9425            std::env::temp_dir().join(format!("spar-repo-test-hunks-{}", std::process::id()));
9426        let _cleanup = ReviewFixture { root: root.clone() };
9427        let origin = root.join("origin.git");
9428        let work = root.join("work");
9429        let marker = root.join("textconv-ran");
9430        std::fs::create_dir_all(&origin).unwrap();
9431        std::fs::create_dir_all(&work).unwrap();
9432        test_git(&origin, &["init", "--bare", "-b", "main"]);
9433        test_git(&work, &["init", "-b", "main"]);
9434        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9435        test_git(&work, &["config", "user.name", "spar test"]);
9436        test_git(&work, &["config", "commit.gpgsign", "false"]);
9437        test_git(&work, &["config", "diff.noprefix", "true"]);
9438        test_git(&work, &["config", "diff.context", "0"]);
9439        test_git(&work, &["config", "diff.interHunkContext", "10"]);
9440        test_git(&work, &["config", "diff.algorithm", "patience"]);
9441        test_git(&work, &["config", "color.diff", "always"]);
9442        test_git(
9443            &work,
9444            &[
9445                "config",
9446                "diff.conv.textconv",
9447                &format!("touch {}; cat", marker.display()),
9448            ],
9449        );
9450        std::fs::write(work.join(".git/info/attributes"), "*.txt diff=conv\n").unwrap();
9451        let lines: String = (1..=20).map(|n| format!("line {n}\n")).collect();
9452        std::fs::write(work.join("src.txt"), &lines).unwrap();
9453        test_git(&work, &["add", "."]);
9454        test_git(&work, &["commit", "-m", "seed"]);
9455        test_git(
9456            &work,
9457            &["remote", "add", "origin", origin.to_str().unwrap()],
9458        );
9459        test_git(&work, &["push", "-u", "origin", "main"]);
9460        test_git(&work, &["checkout", "-b", "feature"]);
9461        std::fs::write(
9462            work.join("src.txt"),
9463            lines
9464                .replace("line 5\n", "changed 5\n")
9465                .replace("line 15\n", "changed 15\n"),
9466        )
9467        .unwrap();
9468        std::fs::write(work.join("new.txt"), "a\nb\n").unwrap();
9469        std::fs::write(work.join("with space.txt"), "a\n").unwrap();
9470        test_git(&work, &["add", "."]);
9471        test_git(&work, &["commit", "-m", "change"]);
9472        let cfg = crate::config::parse(
9473            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9474        )
9475        .unwrap();
9476        let repo = Repo::open(&work, &cfg).unwrap();
9477        assert_eq!(
9478            Hunks::from([
9479                ("new.txt".to_string(), vec![(1, 2)]),
9480                ("src.txt".to_string(), vec![(2, 8), (12, 18)]),
9481                ("with space.txt".to_string(), vec![(1, 1)]),
9482            ]),
9483            repo.diff_hunks(&work, "main")
9484        );
9485        assert!(!marker.exists(), "the text converter ran");
9486        // The converter is real: a plain diff with the same settings runs it.
9487        test_git(&work, &["diff", "origin/main...HEAD"]);
9488        assert!(marker.exists(), "the fixture's converter never runs at all");
9489    }
9490
9491    fn review_fixture(
9492        tag: &str,
9493        number: i64,
9494    ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
9495        use std::sync::atomic::{AtomicU32, Ordering};
9496        static NEXT: AtomicU32 = AtomicU32::new(0);
9497        let id = NEXT.fetch_add(1, Ordering::Relaxed);
9498        let root =
9499            std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
9500        let origin = root.join("origin.git");
9501        let work = root.join("work");
9502        std::fs::create_dir_all(&origin).unwrap();
9503        std::fs::create_dir_all(&work).unwrap();
9504        test_git(&origin, &["init", "--bare", "-b", "main"]);
9505        test_git(&work, &["init", "-b", "main"]);
9506        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9507        test_git(&work, &["config", "user.name", "spar test"]);
9508        test_git(&work, &["config", "commit.gpgsign", "false"]);
9509        test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
9510        test_git(&work, &["config", "filter.drop.smudge", "cat"]);
9511        std::fs::write(work.join("README.md"), "seed\n").unwrap();
9512        std::fs::write(work.join("data.txt"), "old\n").unwrap();
9513        std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
9514        std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
9515        test_git(&work, &["add", "."]);
9516        test_git(&work, &["commit", "-m", "seed"]);
9517        test_git(
9518            &work,
9519            &["remote", "add", "origin", origin.to_str().unwrap()],
9520        );
9521        test_git(&work, &["push", "-u", "origin", "main"]);
9522        test_git(
9523            &work,
9524            &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
9525        );
9526        let cfg = crate::config::parse(
9527            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9528        )
9529        .unwrap();
9530        let repo = Repo::open(&work, &cfg).unwrap();
9531        let path = repo.worktree_for_pr_head(number).unwrap();
9532        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9533        (ReviewFixture { root }, repo, path, checkpoint)
9534    }
9535
9536    #[test]
9537    fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
9538        let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
9539
9540        repo.release_review_worktree_checked(901, &checkpoint)
9541            .unwrap();
9542
9543        assert!(!path.exists());
9544    }
9545
9546    #[test]
9547    fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
9548        let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
9549        let work = fixture.root.join("work");
9550        let seed = test_git(&work, &["rev-parse", "HEAD"]);
9551        let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
9552        // Heads only the pull request holds, the second rewritten rather than
9553        // built on the first, as a rebase and force push leave them.
9554        let publish = |message: &str| {
9555            let oid = test_git(
9556                &work,
9557                &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
9558            );
9559            let oid = oid.trim().to_string();
9560            test_git(
9561                &work,
9562                &[
9563                    "push",
9564                    "--force",
9565                    "origin",
9566                    &format!("{oid}:refs/pull/923/head"),
9567                ],
9568            );
9569            oid
9570        };
9571        let reviewed = publish("reviewed head");
9572        repo.worktree_for_pr_head(923).unwrap();
9573        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
9574
9575        let rewritten = publish("rewritten head");
9576        repo.worktree_for_pr_head(923).unwrap();
9577
9578        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
9579    }
9580
9581    #[test]
9582    fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
9583        let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
9584        let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
9585        std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
9586        test_git(&path, &["add", "recovery.txt"]);
9587        test_git(&path, &["commit", "-m", "recovery commit"]);
9588        let recovery = test_git(&path, &["rev-parse", "HEAD"]);
9589        test_git(&path, &["reset", "--hard", "main"]);
9590
9591        assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
9592        test_git(
9593            &path,
9594            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9595        );
9596    }
9597
9598    #[test]
9599    fn a_review_ref_reflog_only_commit_prevents_deletion() {
9600        let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
9601        let local_ref = review_ref(921);
9602        let original = test_git(&path, &["rev-parse", &local_ref]);
9603        let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
9604        let recovery = test_git(
9605            &path,
9606            &[
9607                "commit-tree",
9608                tree.trim(),
9609                "-p",
9610                original.trim(),
9611                "-m",
9612                "review ref recovery",
9613            ],
9614        );
9615        test_git(
9616            &path,
9617            &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
9618        );
9619        test_git(
9620            &path,
9621            &["update-ref", &local_ref, original.trim(), recovery.trim()],
9622        );
9623
9624        assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
9625        assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
9626        test_git(
9627            &path,
9628            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9629        );
9630    }
9631
9632    #[test]
9633    fn an_unpublished_commit_message_draft_is_recoverable() {
9634        let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
9635        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9636        let git_dir = if raw.is_absolute() {
9637            raw
9638        } else {
9639            path.join(raw)
9640        };
9641        std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
9642
9643        assert!(repository_has_recoverable_work(&path, true).unwrap());
9644        assert_eq!(
9645            "unique recovery draft\n",
9646            std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
9647        );
9648    }
9649
9650    #[test]
9651    fn a_changed_review_worktree_is_retained_after_a_checked_read() {
9652        let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
9653        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9654
9655        let error = repo
9656            .release_review_worktree_checked(902, &checkpoint)
9657            .unwrap_err();
9658
9659        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9660        assert!(error.to_string().contains("kept for recovery"), "{error}");
9661        assert_eq!(
9662            "recover me\n",
9663            std::fs::read_to_string(path.join("README.md")).unwrap()
9664        );
9665        repo.release_review_worktree(902);
9666    }
9667
9668    #[test]
9669    fn a_review_commit_is_retained_after_a_checked_read() {
9670        let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
9671        std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
9672        test_git(&path, &["add", "review-note.txt"]);
9673        test_git(&path, &["commit", "-m", "local review recovery"]);
9674        let head = test_git(&path, &["rev-parse", "HEAD"]);
9675
9676        let error = repo
9677            .release_review_worktree_checked(903, &checkpoint)
9678            .unwrap_err();
9679
9680        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9681        assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
9682        assert_eq!(
9683            "recover me\n",
9684            std::fs::read_to_string(path.join("review-note.txt")).unwrap()
9685        );
9686        repo.release_review_worktree(903);
9687    }
9688
9689    /// The read is no longer what refuses: a file the call created and Git was
9690    /// told to ignore is its own output. Removal still refuses, so the file is
9691    /// there to look at, which is the part that mattered.
9692    #[test]
9693    fn an_ignored_review_file_is_retained_after_a_checked_read() {
9694        let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
9695        std::fs::create_dir_all(path.join("generated")).unwrap();
9696        std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
9697
9698        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9699            .expect("its own output is not a change");
9700        let error = repo
9701            .release_review_worktree_checked(904, &checkpoint)
9702            .unwrap_err();
9703
9704        assert!(error.to_string().contains("recoverable"), "{error}");
9705        assert_eq!(
9706            "recover me\n",
9707            std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
9708        );
9709        repo.release_review_worktree(904);
9710    }
9711
9712    #[test]
9713    /// The checkpoint no longer refuses this: a path the project ignores is out
9714    /// of scope for a call that only read. What keeps the file is the gate that
9715    /// actually deletes the checkout, which refuses any ignored file outside a
9716    /// known build directory. That is the check with the stake in it, and it is
9717    /// the one that has to hold.
9718    fn a_preexisting_ignored_review_file_change_is_retained() {
9719        let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
9720        std::fs::create_dir_all(path.join("generated")).unwrap();
9721        let ignored = path.join("generated/recovery.txt");
9722        std::fs::write(&ignored, "before\n").unwrap();
9723        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9724        std::fs::write(&ignored, "after!\n").unwrap();
9725
9726        let error = repo
9727            .release_review_worktree_checked(905, &checkpoint)
9728            .unwrap_err();
9729
9730        assert!(error.to_string().contains("recoverable"), "{error}");
9731        assert!(path.exists());
9732        assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
9733        repo.release_review_worktree(905);
9734    }
9735
9736    #[test]
9737    fn a_preexisting_ignored_review_file_prevents_checked_removal() {
9738        let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
9739        std::fs::create_dir_all(path.join("generated")).unwrap();
9740        let ignored = path.join("generated/recovery.txt");
9741        std::fs::write(&ignored, "keep me\n").unwrap();
9742        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9743
9744        let error = repo
9745            .release_review_worktree_checked(906, &checkpoint)
9746            .unwrap_err();
9747
9748        assert!(error.to_string().contains("recoverable"), "{error}");
9749        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
9750    }
9751
9752    #[test]
9753    fn overwriting_a_preexisting_untracked_file_is_detected() {
9754        let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
9755        let untracked = path.join("notes.txt");
9756        std::fs::write(&untracked, "before\n").unwrap();
9757        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9758        std::fs::write(&untracked, "after!\n").unwrap();
9759
9760        let error = repo
9761            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9762            .unwrap_err();
9763
9764        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9765        assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
9766    }
9767
9768    #[test]
9769    fn an_assume_unchanged_edit_is_detected() {
9770        let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
9771        test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
9772        std::fs::write(path.join("README.md"), "hidden\n").unwrap();
9773
9774        let error = repo
9775            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9776            .unwrap_err();
9777
9778        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9779        assert_eq!(
9780            "hidden\n",
9781            std::fs::read_to_string(path.join("README.md")).unwrap()
9782        );
9783    }
9784
9785    #[test]
9786    fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
9787        let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
9788        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9789        test_git(&path, &["add", "README.md"]);
9790        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9791
9792        let error = repo
9793            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9794            .unwrap_err();
9795
9796        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9797        assert_eq!(
9798            b"seed\r\n",
9799            std::fs::read(path.join("README.md")).unwrap().as_slice()
9800        );
9801    }
9802
9803    #[cfg(unix)]
9804    #[test]
9805    fn a_mode_edit_is_detected_when_filemode_is_disabled() {
9806        use std::os::unix::fs::PermissionsExt;
9807
9808        let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
9809        test_git(&path, &["config", "core.filemode", "false"]);
9810        let readme = path.join("README.md");
9811        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9812        permissions.set_mode(0o755);
9813        std::fs::set_permissions(&readme, permissions).unwrap();
9814        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9815
9816        let error = repo
9817            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9818            .unwrap_err();
9819
9820        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9821        assert_eq!(
9822            0o755,
9823            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9824        );
9825    }
9826
9827    #[test]
9828    fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
9829        let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
9830        std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
9831        test_git(&path, &["add", ".gitattributes"]);
9832        test_git(&path, &["commit", "-m", "select data filter"]);
9833        let baseline = repo.worktree_baseline(&path).unwrap();
9834        std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
9835
9836        assert!(repo
9837            .commit_pending_changes(&path, &baseline, "change data", "change data")
9838            .unwrap());
9839        let error = repo
9840            .refuse_unrepresented_tracked_changes(&path, &baseline)
9841            .unwrap_err();
9842
9843        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9844        assert_eq!(
9845            "secret: recover me\nnew\n",
9846            std::fs::read_to_string(path.join("data.txt")).unwrap()
9847        );
9848        assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
9849    }
9850
9851    #[test]
9852    fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
9853        let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
9854        std::fs::create_dir_all(path.join("target")).unwrap();
9855        let untracked = path.join("target/user.yaml");
9856        std::fs::write(&untracked, "user data\n").unwrap();
9857        let baseline = repo.worktree_baseline(&path).unwrap();
9858        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9859
9860        assert!(repo
9861            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9862            .unwrap());
9863
9864        assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
9865        assert_eq!(
9866            "?? target/user.yaml\n",
9867            test_git(&path, &["status", "--short", "--untracked-files=all"])
9868        );
9869        assert!(test_git(
9870            &path,
9871            &[
9872                "ls-tree",
9873                "-r",
9874                "--name-only",
9875                "HEAD",
9876                "--",
9877                "target/user.yaml"
9878            ]
9879        )
9880        .is_empty());
9881    }
9882
9883    #[test]
9884    fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
9885        let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
9886        std::fs::create_dir_all(path.join("target")).unwrap();
9887        let untracked = path.join("target/user.yaml");
9888        std::fs::write(&untracked, "before\n").unwrap();
9889        let baseline = repo.worktree_baseline(&path).unwrap();
9890        let before = test_git(&path, &["rev-parse", "HEAD"]);
9891        std::fs::write(&untracked, "after\n").unwrap();
9892        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9893
9894        let error = repo
9895            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9896            .unwrap_err();
9897
9898        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9899        assert!(error.to_string().contains("target/user.yaml"), "{error}");
9900        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
9901        assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
9902        assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
9903    }
9904
9905    #[test]
9906    fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
9907        let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
9908        let baseline = repo.worktree_baseline(&path).unwrap();
9909        std::fs::create_dir_all(path.join("target")).unwrap();
9910        std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
9911
9912        assert!(repo
9913            .commit_pending_changes(&path, &baseline, "add file", "add file")
9914            .unwrap());
9915
9916        assert_eq!(
9917            "new file\n",
9918            test_git(&path, &["show", "HEAD:target/new.txt"])
9919        );
9920        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9921    }
9922
9923    #[test]
9924    fn a_managed_commit_accepts_a_custom_output_rebuild() {
9925        let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
9926        exclude_paths(&repo, &["manager/public/"]);
9927        let output = path.join("manager/public/assets");
9928        std::fs::create_dir_all(&output).unwrap();
9929        std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
9930        std::fs::write(output.join("index.css"), "old styles\n").unwrap();
9931        let baseline = repo.worktree_baseline(&path).unwrap();
9932        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9933        std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
9934        std::fs::remove_file(output.join("old.js")).unwrap();
9935        std::fs::write(output.join("index.css"), "new styles\n").unwrap();
9936        std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
9937
9938        assert!(repo
9939            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9940            .unwrap());
9941        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9942        repo.refuse_changed_existing_untracked(&path, &baseline)
9943            .unwrap();
9944        repo.refuse_unrepresented_tracked_changes(&path, &baseline)
9945            .unwrap();
9946
9947        assert_eq!(
9948            "tracked change\n",
9949            test_git(&path, &["show", "HEAD:README.md"])
9950        );
9951        assert_eq!(
9952            "new source\n",
9953            test_git(&path, &["show", "HEAD:new-source.txt"])
9954        );
9955        assert!(test_git(
9956            &path,
9957            &[
9958                "ls-tree",
9959                "-r",
9960                "--name-only",
9961                "HEAD",
9962                "--",
9963                "manager/public"
9964            ]
9965        )
9966        .is_empty());
9967        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9968        assert!(!output.join("old.js").exists());
9969        assert_eq!(
9970            "new styles\n",
9971            std::fs::read_to_string(output.join("index.css")).unwrap()
9972        );
9973        assert!(repository_has_recoverable_work(&path, true).unwrap());
9974        repo.release_review_worktree(912);
9975        assert!(output.join("index-DUym-Rfj.js").exists());
9976    }
9977
9978    #[test]
9979    fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
9980        let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
9981        std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
9982        let baseline = repo.worktree_baseline(&path).unwrap();
9983        std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
9984        std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
9985
9986        let error = repo
9987            .commit_pending_changes(&path, &baseline, "change", "change")
9988            .unwrap_err();
9989
9990        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9991        assert!(error.to_string().contains("notes.txt"), "{error}");
9992    }
9993
9994    #[test]
9995    fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
9996        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
9997        let baseline = repo.worktree_baseline(&path).unwrap();
9998        let nested = path.join("generated/repro");
9999        std::fs::create_dir_all(&nested).unwrap();
10000        test_git(&nested, &["init"]);
10001        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10002
10003        assert!(repo
10004            .commit_pending_changes(&path, &baseline, "change", "change")
10005            .unwrap());
10006        assert!(!repo.has_uncommitted_changes(&path).unwrap());
10007        assert!(repository_has_recoverable_work(&path, true).unwrap());
10008        repo.release_review_worktree(927);
10009        assert!(nested.join(".git").exists());
10010    }
10011
10012    #[test]
10013    fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
10014        let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
10015        test_git(&path, &["config", "core.autocrlf", "true"]);
10016        std::fs::write(path.join("README.md"), "seed\n").unwrap();
10017        assert_eq!(
10018            test_git(&path, &["hash-object", "README.md"]).trim(),
10019            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
10020        );
10021
10022        assert!(repository_has_recoverable_work(&path, true).unwrap());
10023        assert_eq!(
10024            "seed\n",
10025            std::fs::read_to_string(path.join("README.md")).unwrap()
10026        );
10027    }
10028
10029    #[test]
10030    fn autocrlf_input_overrides_a_crlf_core_eol() {
10031        let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
10032        test_git(&path, &["config", "core.autocrlf", "input"]);
10033        test_git(&path, &["config", "core.eol", "crlf"]);
10034        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
10035        assert_eq!(
10036            test_git(&path, &["hash-object", "README.md"]).trim(),
10037            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
10038        );
10039
10040        assert!(repository_has_recoverable_work(&path, true).unwrap());
10041        assert_eq!(
10042            b"seed\r\n",
10043            std::fs::read(path.join("README.md")).unwrap().as_slice()
10044        );
10045    }
10046
10047    #[cfg(unix)]
10048    #[test]
10049    fn a_non_executable_permission_change_is_recoverable() {
10050        use std::os::unix::fs::PermissionsExt;
10051
10052        let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
10053        let readme = path.join("README.md");
10054        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
10055        permissions.set_mode(0o600);
10056        std::fs::set_permissions(&readme, permissions).unwrap();
10057        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10058
10059        let error = repo
10060            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10061            .unwrap_err();
10062
10063        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10064        assert!(repository_has_recoverable_work(&path, true).unwrap());
10065        assert_eq!(
10066            0o600,
10067            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
10068        );
10069    }
10070
10071    #[cfg(unix)]
10072    #[test]
10073    fn a_managed_commit_skips_signing_and_hooks() {
10074        use std::os::unix::fs::PermissionsExt;
10075
10076        let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
10077        let common = common_git_dir(&path).unwrap();
10078        let hook = common.join("hooks/pre-commit");
10079        let marker = fixture.root.join("hook-ran");
10080        std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
10081        std::fs::write(
10082            &hook,
10083            format!(
10084                "#!/bin/sh\nprintf ran > {}\nexit 1\n",
10085                sh_quote(marker.to_str().unwrap())
10086            ),
10087        )
10088        .unwrap();
10089        let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
10090        permissions.set_mode(0o755);
10091        std::fs::set_permissions(&hook, permissions).unwrap();
10092        test_git(&path, &["config", "commit.gpgsign", "true"]);
10093        test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
10094        std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
10095        test_git(&path, &["add", "managed.txt"]);
10096
10097        repo.commit_staged_changes(&path, "record managed change")
10098            .unwrap();
10099
10100        assert!(!marker.exists());
10101        assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
10102    }
10103
10104    #[test]
10105    fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
10106        let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
10107        std::fs::write(
10108            path.join(".gitattributes"),
10109            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
10110        )
10111        .unwrap();
10112        test_git(&path, &["add", ".gitattributes"]);
10113        test_git(&path, &["commit", "-m", "select automatic text"]);
10114        test_git(&path, &["config", "core.autocrlf", "true"]);
10115
10116        assert!(repository_has_recoverable_work(&path, true).unwrap());
10117    }
10118
10119    #[test]
10120    fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
10121        let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
10122        std::fs::write(
10123            path.join(".gitattributes"),
10124            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
10125        )
10126        .unwrap();
10127        test_git(&path, &["add", ".gitattributes"]);
10128        test_git(&path, &["commit", "-m", "select ident expansion"]);
10129        std::fs::write(path.join("README.md"), "seed\n").unwrap();
10130
10131        assert!(repository_has_recoverable_work(&path, true).unwrap());
10132    }
10133
10134    /// Add ignore rules the way SPAR does, without a tracked change the
10135    /// worktree would then be kept for.
10136    /// The failure this whole rule was rewritten for: a reviewer ran the
10137    /// project's binding generator to check a finding, and the lockfile it left
10138    /// beside its own manifest matched no directory name spar knew.
10139    #[test]
10140    fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
10141        let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
10142        exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
10143        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10144        std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
10145        std::fs::write(
10146            path.join("bindings/uniffi-bindgen/Cargo.lock"),
10147            "# generated\n",
10148        )
10149        .unwrap();
10150
10151        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10152            .expect("a path the project ignores is the call's own output");
10153    }
10154
10155    #[test]
10156    fn an_attribute_file_under_build_output_is_not_a_filter_change() {
10157        let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
10158        exclude_paths(&repo, &["target/"]);
10159        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10160        std::fs::create_dir_all(path.join("target/package")).unwrap();
10161        std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
10162
10163        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10164            .expect("an ignored attribute file governs only ignored paths");
10165        assert!(
10166            !repo.has_uncommitted_changes(&path).unwrap(),
10167            "ignored dependency attributes must not stop the next review"
10168        );
10169    }
10170
10171    #[test]
10172    fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
10173        let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
10174        std::fs::create_dir_all(path.join("source")).unwrap();
10175        std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
10176
10177        assert!(repo.has_uncommitted_changes(&path).unwrap());
10178    }
10179
10180    #[test]
10181    fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
10182        for source in ["repository", "nested", "local", "global"] {
10183            let (_fixture, repo, path, _initial) = review_fixture(source, 945);
10184            std::fs::create_dir_all(path.join("manager")).unwrap();
10185            match source {
10186                "repository" => {
10187                    std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
10188                        .unwrap();
10189                    test_git(&path, &["add", ".gitignore"]);
10190                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
10191                }
10192                "nested" => {
10193                    std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
10194                    test_git(&path, &["add", "manager/.gitignore"]);
10195                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
10196                }
10197                "local" => exclude_paths(&repo, &["manager/scratch/"]),
10198                "global" => {
10199                    let excludes = repo.root().parent().unwrap().join("global-ignore");
10200                    std::fs::write(&excludes, "manager/scratch/\n").unwrap();
10201                    test_git(
10202                        &path,
10203                        &["config", "core.excludesFile", excludes.to_str().unwrap()],
10204                    );
10205                }
10206                _ => unreachable!(),
10207            }
10208            let output = path.join("manager/scratch");
10209            std::fs::create_dir_all(&output).unwrap();
10210            std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
10211            std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
10212            let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10213            std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
10214            std::fs::remove_file(output.join(".DS_Store")).unwrap();
10215            for file in [
10216                ".gitattributes",
10217                "Cargo.lock",
10218                "font.woff2",
10219                "bundle.js",
10220                "cache.db",
10221            ] {
10222                std::fs::write(output.join(file), "generated\n").unwrap();
10223            }
10224            #[cfg(unix)]
10225            std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
10226            let nested = output.join("dependency");
10227            std::fs::create_dir_all(&nested).unwrap();
10228            test_git(&nested, &["init"]);
10229            repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10230                .unwrap_or_else(|error| panic!("{source}: {error}"));
10231            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10232
10233            let baseline = repo.worktree_baseline(&path).unwrap();
10234            std::fs::write(path.join("README.md"), "source fix\n").unwrap();
10235            std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
10236            std::fs::remove_file(output.join("Cargo.lock")).unwrap();
10237            std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
10238            std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
10239            assert!(repo
10240                .commit_pending_changes(&path, &baseline, "fix source", "fix source")
10241                .unwrap_or_else(|error| panic!("{source}: {error}")));
10242            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10243            assert!(test_git(
10244                &path,
10245                &[
10246                    "ls-tree",
10247                    "-r",
10248                    "--name-only",
10249                    "HEAD",
10250                    "--",
10251                    "manager/scratch"
10252                ]
10253            )
10254            .is_empty());
10255            assert!(
10256                repository_has_recoverable_work(&path, true).unwrap(),
10257                "{source}"
10258            );
10259            repo.release_review_worktree(945);
10260            assert_eq!(
10261                "LOCAL=updated\n",
10262                std::fs::read_to_string(output.join(".env.local")).unwrap()
10263            );
10264            assert!(nested.join(".git").exists(), "{source}");
10265        }
10266    }
10267
10268    /// Two reviewers share one checkout. The marker written when the first
10269    /// one's answer was refused used to be a brand new untracked file in the
10270    /// snapshot the second one was compared against.
10271    #[test]
10272    fn a_recovery_marker_is_not_a_change_to_the_worktree() {
10273        let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
10274        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10275        let _ = uncertain_worktree_change(&path, "an earlier call was refused");
10276
10277        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10278            .expect("spar's own marker is not something a call did");
10279    }
10280
10281    #[test]
10282    fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
10283        let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
10284
10285        let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
10286            .expect("a path a build removed is not an unreadable worktree");
10287
10288        assert_eq!(vanished_untracked_file(), gone);
10289    }
10290
10291    /// A path that goes is still a path that was there. Dropping it from the
10292    /// snapshot would let it pass for one the call never touched.
10293    #[test]
10294    fn a_vanished_path_is_not_recorded_as_absent() {
10295        let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
10296        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
10297        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10298        std::fs::remove_file(path.join("notes.txt")).unwrap();
10299
10300        let error = repo
10301            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10302            .unwrap_err();
10303
10304        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10305    }
10306
10307    fn exclude_paths(repo: &Repo, lines: &[&str]) {
10308        use std::io::Write;
10309        let path = repo.root().join(".git").join("info").join("exclude");
10310        let mut file = std::fs::OpenOptions::new()
10311            .create(true)
10312            .append(true)
10313            .open(&path)
10314            .unwrap();
10315        for line in lines {
10316            writeln!(file, "{line}").unwrap();
10317        }
10318    }
10319
10320    #[test]
10321    fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
10322        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
10323        exclude_paths(&repo, &["dist/"]);
10324        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10325        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10326        let baseline = repo.worktree_baseline(&path).unwrap();
10327        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10328        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10329        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10330        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10331
10332        assert!(repo
10333            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10334            .unwrap());
10335        assert_eq!(
10336            "tracked change\n",
10337            std::fs::read_to_string(path.join("README.md")).unwrap()
10338        );
10339    }
10340
10341    #[test]
10342    fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
10343        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
10344        exclude_paths(&repo, &["dist/"]);
10345        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10346        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10347        std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
10348        let baseline = repo.worktree_baseline(&path).unwrap();
10349        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10350        std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
10351        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10352        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10353
10354        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10355        repo.refuse_changed_existing_untracked(&path, &baseline)
10356            .unwrap();
10357    }
10358
10359    #[test]
10360    fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
10361        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
10362        exclude_paths(&repo, &["dist/"]);
10363        std::fs::create_dir_all(path.join("dist")).unwrap();
10364        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10365        let baseline = repo.worktree_baseline(&path).unwrap();
10366        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10367        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10368
10369        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10370        repo.refuse_changed_existing_untracked(&path, &baseline)
10371            .unwrap();
10372    }
10373
10374    /// The run this comes from: a reviewer rebuilt a project whose build output
10375    /// lands in `manager/public/assets/`, which no directory list will ever
10376    /// name. The guard inside the call kept the review. This check then called
10377    /// the same thirteen files unrepresentable and ended the round, five
10378    /// minutes of review thrown away over a path Git had been told to disown.
10379    /// The run this comes from: `gh pr merge --delete-branch` could not delete
10380    /// `issue-784` because `.spar-worktrees/issue-784`, left by the run that
10381    /// opened the pull request, still had it checked out.
10382    #[test]
10383    fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
10384        let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
10385        let held = repo.root().join(WORKTREE_DIR).join("issue-950");
10386        std::fs::create_dir_all(held.parent().unwrap()).unwrap();
10387        test_git(
10388            repo.root(),
10389            &[
10390                "worktree",
10391                "add",
10392                "-b",
10393                "issue-950",
10394                held.to_str().unwrap(),
10395                "main",
10396            ],
10397        );
10398
10399        let found = repo
10400            .worktree_holding("issue-950")
10401            .expect("the branch is checked out somewhere");
10402
10403        assert!(found.ends_with("issue-950"), "{}", found.display());
10404        assert_eq!(None, repo.worktree_holding("issue-951"));
10405    }
10406
10407    #[test]
10408    fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
10409        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
10410        exclude_paths(&repo, &["manager/public/assets/"]);
10411        std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
10412        let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
10413        std::fs::write(&bundle, "first build\n").unwrap();
10414        let baseline = repo.worktree_baseline(&path).unwrap();
10415        std::fs::write(&bundle, "second build\n").unwrap();
10416        std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
10417
10418        repo.refuse_read_only_leavings(&path, &baseline).unwrap();
10419        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10420    }
10421
10422    /// A repro test a reviewer left behind is moved out with its path kept,
10423    /// and the checkout matches its checkpoint again.
10424    #[test]
10425    fn a_file_a_reviewer_created_is_moved_aside_and_the_checkout_is_whole_again() {
10426        let (_fixture, repo, path, checkpoint) = review_fixture("scratch-aside", 945);
10427        std::fs::create_dir_all(path.join("tests")).unwrap();
10428        std::fs::write(path.join("tests/zz-repro.test.ts"), "repro\n").unwrap();
10429
10430        repo.set_aside_review_scratch(&path, &checkpoint, 945);
10431
10432        assert!(!path.join("tests/zz-repro.test.ts").exists());
10433        let shelves: Vec<PathBuf> = std::fs::read_dir(repo.root().join(".spar/scratch"))
10434            .unwrap()
10435            .flatten()
10436            .map(|entry| entry.path())
10437            .collect();
10438        assert_eq!(1, shelves.len(), "{shelves:?}");
10439        assert_eq!(
10440            "repro\n",
10441            std::fs::read_to_string(shelves[0].join("tests/zz-repro.test.ts")).unwrap()
10442        );
10443        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10444            .unwrap();
10445    }
10446
10447    /// Only what was not there before is moved. A rewritten tracked file, or
10448    /// an untracked one that was already there, stays put for the check to
10449    /// refuse.
10450    #[test]
10451    fn only_a_file_that_was_not_there_is_moved_aside() {
10452        let (_fixture, repo, path, _checkpoint) = review_fixture("scratch-only-new", 946);
10453        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
10454        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10455        std::fs::write(path.join("README.md"), "rewritten\n").unwrap();
10456        std::fs::write(path.join("notes.txt"), "after\n").unwrap();
10457
10458        repo.set_aside_review_scratch(&path, &checkpoint, 946);
10459
10460        assert_eq!(
10461            "rewritten\n",
10462            std::fs::read_to_string(path.join("README.md")).unwrap()
10463        );
10464        assert_eq!(
10465            "after\n",
10466            std::fs::read_to_string(path.join("notes.txt")).unwrap()
10467        );
10468        assert!(!repo.root().join(".spar/scratch").exists());
10469        let error = repo
10470            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10471            .unwrap_err();
10472        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10473        assert!(error.to_string().contains("README.md"), "{error}");
10474        assert!(error.to_string().contains("notes.txt"), "{error}");
10475    }
10476
10477    #[test]
10478    fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
10479        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
10480        exclude_paths(&repo, &["dist/"]);
10481        let baseline = repo.worktree_baseline(&path).unwrap();
10482        std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
10483
10484        let error = repo
10485            .refuse_read_only_leavings(&path, &baseline)
10486            .unwrap_err();
10487
10488        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10489        assert!(error.to_string().contains("scratch.md"), "{error}");
10490    }
10491
10492    #[test]
10493    fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
10494        let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
10495        exclude_paths(&repo, &["dist/", ".env.local"]);
10496        std::fs::create_dir_all(path.join("dist")).unwrap();
10497        let baseline = repo.worktree_baseline(&path).unwrap();
10498        std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
10499        std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
10500
10501        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10502        assert!(repository_has_recoverable_work(&path, true).unwrap());
10503    }
10504
10505    #[test]
10506    fn a_read_only_inspection_may_rebuild_generated_output() {
10507        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
10508        exclude_paths(&repo, &["dist/"]);
10509        std::fs::create_dir_all(path.join("dist")).unwrap();
10510        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10511        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10512        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10513        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10514
10515        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10516            .unwrap();
10517    }
10518
10519    #[test]
10520    fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
10521        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
10522        exclude_paths(&repo, &["dist/", ".env.local"]);
10523        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10524        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10525        std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
10526
10527        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10528            .unwrap();
10529    }
10530
10531    #[test]
10532    fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
10533        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
10534        exclude_paths(&repo, &["dist/", ".env.local"]);
10535        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10536        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10537        std::fs::remove_file(path.join(".env.local")).unwrap();
10538
10539        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10540            .unwrap();
10541    }
10542
10543    #[test]
10544    fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
10545        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
10546        exclude_paths(&repo, &["dist/"]);
10547        std::fs::write(path.join("notes.md"), "before\n").unwrap();
10548        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10549        std::fs::write(path.join("notes.md"), "after\n").unwrap();
10550
10551        let error = repo
10552            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10553            .unwrap_err();
10554
10555        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10556        assert!(error.to_string().contains("notes.md"), "{error}");
10557    }
10558
10559    #[test]
10560    fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
10561        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
10562        exclude_paths(&repo, &["dist/", "local.env"]);
10563        std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
10564        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10565        // Stop ignoring it, so the same bytes are now an ordinary untracked
10566        // file. What a path is counts, not only whether it moved.
10567        std::fs::write(
10568            repo.root().join(".git").join("info").join("exclude"),
10569            "dist/\n",
10570        )
10571        .unwrap();
10572
10573        let error = repo
10574            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10575            .unwrap_err();
10576
10577        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10578        assert!(error.to_string().contains("local.env"), "{error}");
10579    }
10580
10581    #[test]
10582    fn a_read_only_inspection_names_the_tracked_file_that_moved() {
10583        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
10584        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10585        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
10586
10587        let error = repo
10588            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10589            .unwrap_err();
10590
10591        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10592        assert!(error.to_string().contains("README.md"), "{error}");
10593    }
10594
10595    #[test]
10596    fn build_output_alone_does_not_keep_a_worktree() {
10597        let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
10598        exclude_paths(&repo, &["target/", "dist/"]);
10599        std::fs::create_dir_all(path.join("target/debug")).unwrap();
10600        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10601        std::fs::create_dir_all(path.join("dist/cli")).unwrap();
10602        std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
10603
10604        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10605        repo.release_review_worktree(933);
10606
10607        assert!(!path.exists());
10608    }
10609
10610    #[test]
10611    fn finder_metadata_alone_does_not_keep_a_worktree() {
10612        let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
10613        exclude_paths(&repo, &[".DS_Store"]);
10614        std::fs::create_dir_all(path.join("src")).unwrap();
10615        std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
10616        std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
10617
10618        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10619        repo.release_review_worktree(937);
10620
10621        assert!(!path.exists());
10622    }
10623
10624    #[test]
10625    fn a_fetch_record_alone_does_not_keep_a_worktree() {
10626        let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
10627        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
10628        let git_dir = if raw.is_absolute() {
10629            raw
10630        } else {
10631            path.join(raw)
10632        };
10633        let head = test_git(&path, &["rev-parse", "HEAD"]);
10634        std::fs::write(
10635            git_dir.join("FETCH_HEAD"),
10636            format!("{}\t\tbranch 'main' of origin\n", head.trim()),
10637        )
10638        .unwrap();
10639
10640        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10641        repo.release_review_worktree(938);
10642
10643        assert!(!path.exists());
10644    }
10645
10646    #[test]
10647    fn an_ignored_file_outside_build_output_keeps_a_worktree() {
10648        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
10649        exclude_paths(&repo, &["target/", ".env.local"]);
10650        std::fs::create_dir_all(path.join("target/debug")).unwrap();
10651        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10652        std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
10653
10654        assert!(repository_has_recoverable_work(&path, true).unwrap());
10655        repo.release_review_worktree(934);
10656
10657        assert_eq!(
10658            "TOKEN=keep me\n",
10659            std::fs::read_to_string(path.join(".env.local")).unwrap()
10660        );
10661    }
10662
10663    #[test]
10664    fn a_repository_nested_in_build_output_keeps_a_worktree() {
10665        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
10666        exclude_paths(&repo, &["node_modules/"]);
10667        let nested = path.join("node_modules/local-dep");
10668        std::fs::create_dir_all(&nested).unwrap();
10669        test_git(&nested, &["init"]);
10670        std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
10671
10672        assert!(repository_has_recoverable_work(&path, true).unwrap());
10673        repo.release_review_worktree(935);
10674
10675        assert!(nested.join(".git").exists());
10676    }
10677
10678    #[test]
10679    fn an_ordinary_untracked_file_keeps_a_worktree() {
10680        let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
10681        std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
10682
10683        assert!(repository_has_recoverable_work(&path, true).unwrap());
10684        repo.release_review_worktree(936);
10685
10686        assert_eq!(
10687            "somebody's notes\n",
10688            std::fs::read_to_string(path.join("notes.md")).unwrap()
10689        );
10690    }
10691
10692    #[test]
10693    fn a_legacy_crlf_checkout_is_retained_conservatively() {
10694        let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
10695        std::fs::write(
10696            path.join(".gitattributes"),
10697            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
10698        )
10699        .unwrap();
10700        test_git(&path, &["add", ".gitattributes"]);
10701        test_git(&path, &["commit", "-m", "select legacy line endings"]);
10702
10703        assert!(repository_has_recoverable_work(&path, true).unwrap());
10704    }
10705
10706    #[test]
10707    fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
10708        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
10709        let nested = path.join("tracked");
10710        std::fs::create_dir_all(&nested).unwrap();
10711        std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
10712        test_git(&path, &["add", "tracked/seed.txt"]);
10713        test_git(&path, &["commit", "-m", "add tracked directory"]);
10714        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10715        test_git(&nested, &["init"]);
10716
10717        let error = repo
10718            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10719            .unwrap_err();
10720
10721        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10722        assert!(error.to_string().contains("Git entry"), "{error}");
10723        assert!(nested.join(".git").exists());
10724    }
10725
10726    /// A reviewer that clones a reproduction into ignored build output used to
10727    /// lose the whole review. The state check refused every Git entry it had
10728    /// not put there, including the one Git reports as a nested checkout and
10729    /// the untracked snapshot already records by identity.
10730    #[test]
10731    fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
10732        let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
10733        exclude_paths(&repo, &["target/"]);
10734        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10735        let repro = path.join("target/pr918-repro");
10736        std::fs::create_dir_all(&repro).unwrap();
10737        test_git(&repro, &["init"]);
10738        std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
10739
10740        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10741            .unwrap();
10742
10743        assert!(repository_has_recoverable_work(&path, true).unwrap());
10744        assert!(repro.join(".git").exists());
10745    }
10746
10747    /// What sits under a nested checkout is that repository's business, so the
10748    /// scan stops at its directory rather than reading a checkout of its own
10749    /// as an entry the outer worktree has to account for.
10750    #[test]
10751    fn a_repository_inside_a_nested_checkout_is_left_alone() {
10752        let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
10753        exclude_paths(&repo, &["target/"]);
10754        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10755        let repro = path.join("target/repro");
10756        let vendored = repro.join("vendor/dep");
10757        std::fs::create_dir_all(&vendored).unwrap();
10758        test_git(&repro, &["init"]);
10759        test_git(&vendored, &["init"]);
10760
10761        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10762            .unwrap();
10763
10764        assert!(vendored.join(".git").exists());
10765    }
10766
10767    #[test]
10768    fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
10769        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
10770
10771        let state = ignored_untracked_state(repo.root()).unwrap();
10772
10773        let relative = path.strip_prefix(repo.root()).unwrap();
10774        assert!(
10775            state.files.contains_key(relative),
10776            "{:?}",
10777            state.files.keys().collect::<Vec<_>>()
10778        );
10779        assert!(state.is_ignored(relative));
10780    }
10781
10782    #[test]
10783    fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
10784        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
10785        let baseline = repo.worktree_baseline(repo.root()).unwrap();
10786        std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
10787        std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
10788
10789        repo.refuse_new_ignored_files(repo.root(), &baseline)
10790            .unwrap();
10791        repo.refuse_changed_existing_untracked(repo.root(), &baseline)
10792            .unwrap();
10793    }
10794
10795    #[test]
10796    fn deleting_a_resident_worktree_during_a_call_is_refused() {
10797        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
10798        let baseline = repo.worktree_baseline(repo.root()).unwrap();
10799        let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
10800        std::fs::remove_dir_all(&path).unwrap();
10801
10802        let read_error = repo
10803            .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
10804            .unwrap_err();
10805        assert!(
10806            read_error.to_string().contains("review-932"),
10807            "{read_error}"
10808        );
10809        let error = repo
10810            .refuse_new_ignored_files(repo.root(), &baseline)
10811            .unwrap_err();
10812
10813        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10814        assert!(error.to_string().contains("review-932"), "{error}");
10815    }
10816
10817    #[test]
10818    fn a_nested_repository_record_is_read_as_a_plain_path() {
10819        let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
10820        assert_eq!(Path::new("vendor/checkout"), path);
10821        assert!(nested);
10822
10823        let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
10824        assert_eq!(Path::new("vendor/notes.txt"), path);
10825        assert!(!nested);
10826
10827        assert!(untracked_record(b"/", "untracked").is_err());
10828    }
10829
10830    #[cfg(unix)]
10831    #[test]
10832    fn a_non_utf8_git_path_is_preserved_without_loss() {
10833        use std::os::unix::ffi::OsStrExt;
10834
10835        let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
10836
10837        assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
10838    }
10839
10840    #[test]
10841    fn guarded_merge_pins_the_reviewed_head() {
10842        let args = merge_pr_args("36", Some("abc123"), true);
10843        assert_eq!(
10844            vec![
10845                "pr",
10846                "merge",
10847                "36",
10848                "--squash",
10849                "--delete-branch",
10850                "--match-head-commit",
10851                "abc123"
10852            ],
10853            args
10854        );
10855    }
10856
10857    #[test]
10858    fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
10859        let pr = PrRef {
10860            number: 7,
10861            url: "https://example.test/pull/7".into(),
10862            title: "part one".into(),
10863        };
10864        let result = reconcile_pr_creation(
10865            "split-34-1",
10866            Err(crate::error::SparError::new("connection lost")),
10867            Ok(Some(pr)),
10868        )
10869        .unwrap();
10870        assert_eq!(7, result.number);
10871    }
10872
10873    #[test]
10874    fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
10875        let error = reconcile_pr_creation(
10876            "split-34-1",
10877            Err(crate::error::SparError::new("permission denied")),
10878            Ok(None),
10879        )
10880        .unwrap_err();
10881        assert!(error.to_string().contains("permission denied"), "{error}");
10882    }
10883
10884    #[test]
10885    fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
10886        let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
10887        assert!(pr_for_base(text, "split-34-2", "split-34-1")
10888            .unwrap()
10889            .is_none());
10890        let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
10891        assert_eq!(7, found.number);
10892    }
10893
10894    #[test]
10895    fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
10896        let result = reconcile_comment_post(
10897            34,
10898            "the summary",
10899            crate::error::SparError::new("connection lost"),
10900            Ok(vec![serde_json::json!({"body": "the summary"})]),
10901        );
10902        assert!(result.is_ok(), "{result:?}");
10903    }
10904
10905    #[test]
10906    fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
10907        let error = reconcile_comment_post(
10908            34,
10909            "the summary",
10910            crate::error::SparError::new("connection lost"),
10911            Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
10912        )
10913        .unwrap_err();
10914        assert_eq!("connection lost", error.to_string());
10915    }
10916
10917    #[test]
10918    fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
10919        let error = reconcile_comment_post(
10920            34,
10921            "the summary",
10922            crate::error::SparError::new("connection lost"),
10923            Err(crate::error::SparError::new("comments unavailable")),
10924        )
10925        .unwrap_err();
10926        assert!(
10927            error.to_string().contains("could not be verified"),
10928            "{error}"
10929        );
10930        assert!(
10931            error.to_string().contains("comments unavailable"),
10932            "{error}"
10933        );
10934        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10935        assert!(!error.worth_retrying());
10936    }
10937
10938    #[test]
10939    fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
10940        let result = reconcile_issue_edit(
10941            34,
10942            "wanted body",
10943            crate::error::SparError::new("connection lost"),
10944            Ok("wanted body".to_string()),
10945        );
10946        assert!(result.is_ok(), "{result:?}");
10947    }
10948
10949    #[test]
10950    fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
10951        let error = reconcile_issue_edit(
10952            34,
10953            "wanted body",
10954            crate::error::SparError::new("connection lost"),
10955            Err(crate::error::SparError::new("issue unavailable")),
10956        )
10957        .unwrap_err();
10958        assert!(
10959            error.to_string().contains("could not be verified"),
10960            "{error}"
10961        );
10962        assert!(error.to_string().contains("issue unavailable"), "{error}");
10963        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10964        assert!(!error.worth_retrying());
10965    }
10966
10967    #[test]
10968    fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
10969        let found = ExistingIssue {
10970            number: 101,
10971            url: "https://example.test/issues/101".into(),
10972            title: "child".into(),
10973            body: "body".into(),
10974            open: true,
10975        };
10976        let url = reconcile_issue_creation(
10977            "child",
10978            Err(crate::error::SparError::new("connection lost")),
10979            Ok(Some(found)),
10980        )
10981        .unwrap();
10982        assert_eq!("https://example.test/issues/101", url);
10983    }
10984
10985    #[test]
10986    fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
10987        let error = reconcile_issue_creation(
10988            "child",
10989            Err(crate::error::SparError::new("permission denied")),
10990            Ok(None),
10991        )
10992        .unwrap_err();
10993        assert!(error.to_string().contains("permission denied"), "{error}");
10994    }
10995
10996    #[test]
10997    fn an_unverifiable_issue_creation_is_marked_uncertain() {
10998        let error = reconcile_issue_creation(
10999            "child",
11000            Err(crate::error::SparError::new("connection lost")),
11001            Err(crate::error::SparError::new("issues unavailable")),
11002        )
11003        .unwrap_err();
11004        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11005        assert!(!error.worth_retrying());
11006    }
11007
11008    #[test]
11009    fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
11010        let result = reconcile_failed_split_push(
11011            "split-34-1",
11012            crate::error::SparError::new("connection lost"),
11013            Ok("abc123\n".into()),
11014            Ok("abc123\trefs/heads/split-34-1\n".into()),
11015        );
11016        assert!(result.is_ok(), "{result:?}");
11017    }
11018
11019    #[test]
11020    fn a_split_push_collision_is_definite_and_never_overwrites() {
11021        let error = reconcile_failed_split_push(
11022            "split-34-1",
11023            crate::error::SparError::new("lease rejected"),
11024            Ok("abc123\n".into()),
11025            Ok("def456\trefs/heads/split-34-1\n".into()),
11026        )
11027        .unwrap_err();
11028        assert!(!error.retain_worktree());
11029        assert!(
11030            error.to_string().contains("Nothing was overwritten"),
11031            "{error}"
11032        );
11033    }
11034
11035    #[test]
11036    fn an_unreadable_split_push_result_keeps_the_worktree() {
11037        let error = reconcile_failed_split_push(
11038            "split-34-1",
11039            crate::error::SparError::new("connection lost"),
11040            Ok("abc123\n".into()),
11041            Err(crate::error::SparError::new("origin unavailable")),
11042        )
11043        .unwrap_err();
11044        assert!(error.retain_worktree());
11045        assert!(error.to_string().contains("could not confirm"), "{error}");
11046    }
11047
11048    /// Follow-up deduplication compares a title it computed against the title
11049    /// GitHub stored. If those two transforms can disagree, the check never
11050    /// matches and every review round files another copy of the same issue.
11051    #[test]
11052    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
11053        let repo = repo_for_titles();
11054        for raw in [
11055            "Retry loop spins \u{2014} Retry-After parses to zero",
11056            "plain title",
11057            "  spread   over\nlines  ",
11058            "\u{1F916} Generated with something",
11059            &format!("a \u{2014} {}", "very long title ".repeat(20)),
11060            &"x".repeat(300),
11061            &format!("{} \u{2014} end", "y".repeat(88)),
11062            // Exactly the budget, with two spaceless dashes. The scrub turns
11063            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
11064            // character over budget per dash and a second pass clips again,
11065            // producing a different string. Scrub-then-clip cannot.
11066            &{
11067                let tail = "a\u{2014}b c\u{2014}d";
11068                let pad = Style::default().max_title_chars - tail.chars().count();
11069                format!("{}{tail}", "w".repeat(pad))
11070            },
11071        ] {
11072            let once = repo.clean_title(raw).unwrap();
11073            let twice = repo.clean_title(&once).unwrap();
11074            assert_eq!(once, twice, "not idempotent for {raw:?}");
11075            assert!(
11076                once.chars().count() <= repo.style.max_title_chars,
11077                "over budget: {once:?}"
11078            );
11079            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
11080        }
11081    }
11082
11083    #[test]
11084    fn a_title_with_an_em_dash_survives_as_readable_text() {
11085        let repo = repo_for_titles();
11086        assert_eq!(
11087            "Retry loop spins, Retry-After parses to zero",
11088            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
11089                .unwrap()
11090        );
11091    }
11092
11093    #[test]
11094    fn sh_quote_survives_a_quote() {
11095        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
11096    }
11097
11098    #[test]
11099    fn sh_quote_wraps_a_space() {
11100        assert_eq!(
11101            "'/Applications/My App/spar'",
11102            sh_quote("/Applications/My App/spar")
11103        );
11104    }
11105
11106    #[test]
11107    fn finished_states_are_recognised_case_insensitively() {
11108        assert!(is_finished("MERGED"));
11109        assert!(is_finished("closed"));
11110        assert!(!is_finished("OPEN"));
11111        assert!(!is_finished(""));
11112    }
11113
11114    fn state() -> PersistedState {
11115        PersistedState {
11116            version: 1,
11117            checkpoint: 0,
11118            round: 4,
11119            next_actor: "codex".into(),
11120            status: Status::Pending,
11121            pr_head: "abc123".into(),
11122            ledger: Ledger::new(),
11123            filed: vec![],
11124            open_findings: vec![Finding {
11125                severity: Severity::Blocking,
11126                title: "Unchecked error".into(),
11127                detail: "the failure is discarded".into(),
11128                file: "src/a.rs:12".into(),
11129                ..Finding::default()
11130            }],
11131            disputes: vec![Dispute {
11132                title: "Retry limit".into(),
11133                file: "src/net.rs".into(),
11134                reasoning: "the caller already bounds it".into(),
11135            }],
11136            noted: vec![Finding {
11137                severity: Severity::NonBlocking,
11138                title: "Timeout is fixed".into(),
11139                file: "src/config.rs".into(),
11140                ..Finding::default()
11141            }],
11142        }
11143    }
11144
11145    #[test]
11146    fn a_state_comment_round_trips() {
11147        let body = format!(
11148            "{STATE_MARKER}\n{}\n-->",
11149            serde_json::to_string(&state()).unwrap()
11150        );
11151        let back = parse_state_comment(&body).unwrap();
11152        assert_eq!(4, back.round);
11153        assert_eq!("codex", back.next_actor);
11154        assert_eq!("abc123", back.pr_head);
11155        assert_eq!("Unchecked error", back.open_findings[0].title);
11156        assert_eq!("src/net.rs", back.disputes[0].file);
11157        assert_eq!("Timeout is fixed", back.noted[0].title);
11158    }
11159
11160    #[test]
11161    fn old_state_without_new_lists_still_parses() {
11162        let body = format!(
11163            "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
11164             \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11165        );
11166        let back = parse_state_comment(&body).expect("old state");
11167        assert!(back.open_findings.is_empty());
11168        assert!(back.disputes.is_empty());
11169        assert!(back.noted.is_empty());
11170        assert!(back.pr_head.is_empty());
11171        assert_eq!(0, back.checkpoint);
11172    }
11173
11174    #[test]
11175    fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
11176        let mut local = state();
11177        local.pr_head = "old".into();
11178        local.round = 9;
11179        let mut remote = state();
11180        remote.pr_head = "current".into();
11181        remote.round = 4;
11182
11183        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11184        assert_eq!("current", chosen.pr_head);
11185        assert_eq!(4, chosen.round);
11186    }
11187
11188    #[test]
11189    fn checkpoint_order_breaks_same_round_ties() {
11190        let mut local = state();
11191        local.pr_head = "current".into();
11192        local.round = 4;
11193        local.checkpoint = 8;
11194        let mut remote = local.clone();
11195        remote.checkpoint = 7;
11196        remote.open_findings.clear();
11197
11198        let chosen = choose_state_for_head(vec![local], "current").unwrap();
11199        assert_eq!(8, chosen.checkpoint);
11200
11201        let mut local = state();
11202        local.pr_head = "current".into();
11203        local.round = 4;
11204        local.checkpoint = 8;
11205        let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
11206        assert_eq!(8, chosen.checkpoint);
11207    }
11208
11209    #[test]
11210    fn legacy_same_round_tie_keeps_the_local_checkpoint() {
11211        let mut local = state();
11212        local.pr_head = "current".into();
11213        local.round = 4;
11214        local.open_findings.push(Finding {
11215            title: "local checkpoint".into(),
11216            ..Finding::default()
11217        });
11218        let mut remote = state();
11219        remote.pr_head = "current".into();
11220        remote.round = 4;
11221
11222        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11223        assert_eq!(
11224            "local checkpoint",
11225            chosen.open_findings.last().unwrap().title
11226        );
11227    }
11228
11229    /// It must render as nothing, so PRs are not littered with machine state.
11230    #[test]
11231    fn the_state_block_is_an_html_comment() {
11232        let body = format!(
11233            "{STATE_MARKER}\n{}\n-->",
11234            serde_json::to_string(&state()).unwrap()
11235        );
11236        assert!(body.starts_with("<!--"));
11237        assert!(body.trim_end().ends_with("-->"));
11238        assert!(!body[..body.find('{').unwrap()].contains("-->"));
11239    }
11240
11241    #[test]
11242    fn an_unrelated_json_block_is_not_state() {
11243        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
11244    }
11245
11246    /// Patching and deleting a comment is not something to do on a substring.
11247    ///
11248    /// A maintainer who notices the block and asks about it writes a comment
11249    /// containing `spar:state`, and the loose filter could not tell theirs from
11250    /// spar's. The next `persist` replaced their words with JSON and
11251    /// `spar clean --pr-state` later deleted it.
11252    #[test]
11253    fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
11254        let block = format!(
11255            "{STATE_MARKER}\n{}\n-->",
11256            serde_json::to_string(&state()).unwrap()
11257        );
11258        assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
11259        assert!(
11260            is_state_comment(&block, "Spar-Bot", "spar-bot"),
11261            "a login differs only in case"
11262        );
11263
11264        assert!(
11265            !is_state_comment(
11266                "why is there a `<!-- spar:state` block on this PR?",
11267                "maintainer",
11268                "spar-bot"
11269            ),
11270            "a question about the block was treated as the block"
11271        );
11272        assert!(
11273            !is_state_comment(&block, "maintainer", "spar-bot"),
11274            "somebody else's comment is not spar's to rewrite"
11275        );
11276        assert!(
11277            !is_state_comment(
11278                &format!("here is what spar writes:\n\n{block}"),
11279                "spar-bot",
11280                "spar-bot"
11281            ),
11282            "the marker has to open the body, not appear in it"
11283        );
11284        assert!(
11285            !is_state_comment(
11286                &format!("{STATE_MARKER}\n{{not json\n-->"),
11287                "spar-bot",
11288                "spar-bot"
11289            ),
11290            "a block that does not parse is not state"
11291        );
11292        assert!(
11293            !is_state_comment(&block, "spar-bot", ""),
11294            "an unknown viewer proves nothing about who wrote it"
11295        );
11296    }
11297
11298    #[test]
11299    fn a_malformed_state_comment_is_none_not_a_panic() {
11300        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
11301    }
11302
11303    #[test]
11304    fn atomic_write_leaves_no_temp_file() {
11305        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
11306        let _ = std::fs::remove_dir_all(&dir);
11307        let path = dir.join("state").join("pr-7.json");
11308        write_json_atomic(&path, &state()).unwrap();
11309        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
11310            .unwrap()
11311            .flatten()
11312            .filter_map(|e| e.file_name().to_str().map(str::to_string))
11313            .collect();
11314        assert_eq!(vec!["pr-7.json".to_string()], files);
11315        let _ = std::fs::remove_dir_all(&dir);
11316    }
11317
11318    #[test]
11319    fn atomic_write_overwrites_rather_than_accumulating() {
11320        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
11321        let _ = std::fs::remove_dir_all(&dir);
11322        let path = dir.join("pr-7.json");
11323        for round in 1..4 {
11324            let mut s = state();
11325            s.round = round;
11326            write_json_atomic(&path, &s).unwrap();
11327        }
11328        let back: PersistedState =
11329            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11330        assert_eq!(3, back.round);
11331        let _ = std::fs::remove_dir_all(&dir);
11332    }
11333
11334    #[test]
11335    fn style_from_env_defaults_to_enforcing() {
11336        std::env::remove_var("SPAR_BAN_EM_DASH");
11337        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
11338        let style = style_from_env();
11339        assert!(style.ban_em_dash && style.ban_ai_attribution);
11340        assert!(
11341            !style.terse,
11342            "the commit filter must not truncate a commit message"
11343        );
11344    }
11345
11346    // -- publishing from a fork --------------------------------------------
11347
11348    /// GitHub's refusal is several lines of advice after the one that says
11349    /// what happened, and spar used to report only the last of them.
11350    #[test]
11351    fn a_push_denial_is_read_from_either_transport() {
11352        let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
11353                   fatal: Could not read from remote repository.\n\n\
11354                   Please make sure you have the correct access rights\n\
11355                   and the repository exists.";
11356        let https = "remote: Permission to example/project.git denied to someone.\n\
11357                     fatal: unable to access 'https://github.com/example/project.git/': The \
11358                     requested URL returned error: 403";
11359        let denied = Some("Permission to example/project.git denied to someone.");
11360        assert_eq!(denied, push_denial(ssh));
11361        assert_eq!(denied, push_denial(https));
11362
11363        let stale = "To github.com:example/project.git\n \
11364                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
11365                     error: failed to push some refs to 'github.com:example/project.git'";
11366        assert_eq!(None, push_denial(stale));
11367
11368        let err = push_error(
11369            &PushTarget::Origin,
11370            "issue-7",
11371            &crate::error::SparError::new(ssh),
11372        );
11373        assert_eq!(
11374            "could not push to origin/issue-7. Permission to example/project.git denied to \
11375             someone.",
11376            err.to_string()
11377        );
11378    }
11379
11380    /// An origin whose hook refuses every push with GitHub's wording, a bare
11381    /// repository standing in for the fork, and a checkout on a new branch
11382    /// with one commit to publish.
11383    #[cfg(unix)]
11384    fn denied_fixture(
11385        tag: &str,
11386        fork_when_denied: bool,
11387    ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
11388        use std::os::unix::fs::PermissionsExt;
11389        use std::sync::atomic::{AtomicU32, Ordering};
11390        static NEXT: AtomicU32 = AtomicU32::new(0);
11391        let id = NEXT.fetch_add(1, Ordering::Relaxed);
11392        let root =
11393            std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
11394        let origin = root.join("origin.git");
11395        let fork = root.join("fork.git");
11396        let work = root.join("work");
11397        for dir in [&origin, &fork, &work] {
11398            std::fs::create_dir_all(dir).unwrap();
11399        }
11400        test_git(&origin, &["init", "--bare", "-b", "main"]);
11401        test_git(&fork, &["init", "--bare", "-b", "main"]);
11402        test_git(&work, &["init", "-b", "main"]);
11403        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
11404        test_git(&work, &["config", "user.name", "spar test"]);
11405        test_git(&work, &["config", "commit.gpgsign", "false"]);
11406        std::fs::write(work.join("README.md"), "seed\n").unwrap();
11407        test_git(&work, &["add", "."]);
11408        test_git(&work, &["commit", "-m", "seed"]);
11409        test_git(
11410            &work,
11411            &["remote", "add", "origin", origin.to_str().unwrap()],
11412        );
11413        test_git(&work, &["push", "-q", "origin", "main"]);
11414        // Installed after the seed, so it is only spar's pushes it refuses.
11415        let hook = origin.join("hooks").join("pre-receive");
11416        std::fs::write(
11417            &hook,
11418            "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
11419        )
11420        .unwrap();
11421        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
11422        test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
11423        std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
11424        test_git(&work, &["add", "."]);
11425        test_git(&work, &["commit", "-m", "Fix it"]);
11426
11427        let mut text =
11428            String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
11429        if !fork_when_denied {
11430            text.push_str("[loop]\nfork_when_denied = false\n");
11431        }
11432        let cfg = crate::config::parse(&text).unwrap();
11433        let repo = Repo::open(&work, &cfg).unwrap();
11434        // What `fork` would have asked GitHub for, so no test reaches it.
11435        repo.fork
11436            .set(Fork {
11437                owner: "someone".into(),
11438                full_name: "someone/project".into(),
11439                url: fork.to_str().unwrap().into(),
11440            })
11441            .unwrap();
11442        (ReviewFixture { root }, repo, work, origin, fork)
11443    }
11444
11445    #[cfg(unix)]
11446    #[test]
11447    fn a_new_branch_origin_refuses_is_published_from_the_fork() {
11448        let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
11449        let first = test_git(&work, &["rev-parse", "HEAD"]);
11450
11451        let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11452
11453        assert_eq!("someone:issue-7", target.head("issue-7"));
11454        assert_eq!(
11455            first.trim(),
11456            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11457        );
11458        assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11459            .trim()
11460            .is_empty());
11461        assert_eq!(
11462            WriteSummary {
11463                attempted: 1,
11464                failed: 0
11465            },
11466            repo.write_summary(),
11467            "a refusal the fork answered is not a failed write"
11468        );
11469
11470        // A later round follows the branch to the fork, leasing what it
11471        // published there.
11472        std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
11473        test_git(&work, &["commit", "-qam", "Fix it again"]);
11474        repo.push_to(&work, &target, "issue-7", Some(first.trim()))
11475            .unwrap();
11476        let second = test_git(&work, &["rev-parse", "HEAD"]);
11477        assert_eq!(
11478            second.trim(),
11479            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11480        );
11481    }
11482
11483    /// What a closed pull request leaves on the fork: a branch with nothing
11484    /// a pull request does not hold. Origin's issue branches are rebuilt over
11485    /// in the same case.
11486    #[cfg(unix)]
11487    #[test]
11488    fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
11489        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
11490        test_git(
11491            &work,
11492            &[
11493                "push",
11494                "-q",
11495                fork.to_str().unwrap(),
11496                "main:refs/heads/issue-7",
11497            ],
11498        );
11499        let head = test_git(&work, &["rev-parse", "HEAD"]);
11500
11501        repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11502
11503        assert_eq!(
11504            head.trim(),
11505            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11506        );
11507        assert_eq!(0, repo.write_summary().failed);
11508    }
11509
11510    #[cfg(unix)]
11511    #[test]
11512    fn work_only_the_fork_has_is_not_published_over() {
11513        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
11514        test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
11515        std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
11516        test_git(&work, &["add", "."]);
11517        test_git(&work, &["commit", "-qm", "Somebody's work"]);
11518        test_git(
11519            &work,
11520            &[
11521                "push",
11522                "-q",
11523                fork.to_str().unwrap(),
11524                "elsewhere:refs/heads/issue-7",
11525            ],
11526        );
11527        let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
11528        test_git(&work, &["checkout", "-q", "issue-7"]);
11529
11530        let err = repo
11531            .publish_new_branch(&work, "issue-7", "main")
11532            .unwrap_err()
11533            .to_string();
11534
11535        assert!(
11536            err.contains(
11537                "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
11538            ),
11539            "{err}"
11540        );
11541        assert!(err.contains("--delete issue-7"), "{err}");
11542        assert_eq!(
11543            theirs.trim(),
11544            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11545        );
11546        assert_eq!(
11547            WriteSummary {
11548                attempted: 1,
11549                failed: 1
11550            },
11551            repo.write_summary()
11552        );
11553    }
11554
11555    /// A lease that loses says which ref and why, not git's closing line.
11556    #[test]
11557    fn a_rejected_push_names_the_ref_it_rejected() {
11558        let stale = "To github.com:example/project.git\n \
11559                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
11560                     error: failed to push some refs to 'github.com:example/project.git'";
11561
11562        let err = push_error(
11563            &PushTarget::Origin,
11564            "issue-7",
11565            &crate::error::SparError::new(stale),
11566        )
11567        .to_string();
11568
11569        assert!(
11570            err.starts_with(
11571                "could not push to origin/issue-7. [rejected]        HEAD -> issue-7 (stale info)"
11572            ),
11573            "{err}"
11574        );
11575    }
11576
11577    #[test]
11578    fn a_fork_is_reached_by_whatever_reaches_origin() {
11579        let cases = [
11580            (
11581                "git@github.com:example/project.git",
11582                Some("git@github.com:someone/project.git"),
11583            ),
11584            // No user: ~/.ssh/config supplies it.
11585            (
11586                "github.com:example/project.git",
11587                Some("github.com:someone/project.git"),
11588            ),
11589            // A host alias, how one machine pushes as two accounts.
11590            (
11591                "git@github-work:example/project.git",
11592                Some("git@github-work:someone/project.git"),
11593            ),
11594            // SSH over 443, where 22 is blocked.
11595            (
11596                "ssh://git@ssh.github.com:443/example/project.git",
11597                Some("ssh://git@ssh.github.com:443/someone/project.git"),
11598            ),
11599            (
11600                "https://github.com/example/project",
11601                Some("https://github.com/someone/project"),
11602            ),
11603            (
11604                "https://ghe.example.com/scm/example/project.git/",
11605                Some("https://ghe.example.com/scm/someone/project.git"),
11606            ),
11607            (
11608                "git@host:/srv/example/project.git",
11609                Some("git@host:/srv/someone/project.git"),
11610            ),
11611            ("/srv/git/project.git", None),
11612            ("https://github.com/project", None),
11613            ("", None),
11614        ];
11615        for (origin, want) in cases {
11616            assert_eq!(
11617                want.map(str::to_string),
11618                fork_url(origin, "someone/project"),
11619                "{origin}"
11620            );
11621        }
11622    }
11623
11624    #[test]
11625    fn a_fork_found_or_made_is_reached_the_way_origin_is() {
11626        let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
11627            "ssh_url":"git@github.com:someone/project.git",
11628            "clone_url":"https://github.com/someone/project.git","fork":true}"#;
11629        let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
11630            "sshUrl":"git@github.com:someone/project.git",
11631            "url":"https://github.com/someone/project"}]"#;
11632
11633        let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
11634        assert_eq!("git@github-work:someone/project.git", fork.url);
11635        assert_eq!("someone", fork.owner);
11636        assert_eq!("someone/project", fork.full_name);
11637        let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
11638            .unwrap()
11639            .unwrap();
11640        assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
11641        assert_eq!(
11642            None,
11643            fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
11644        );
11645
11646        // An origin with no owner/repo path falls back to GitHub's URL, the
11647        // SSH one when origin looks like SSH.
11648        let fork = fork_from_api(made, "https://github.com/project").unwrap();
11649        assert_eq!("https://github.com/someone/project.git", fork.url);
11650        let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
11651        assert_eq!("git@github.com:someone/project.git", fork.url);
11652
11653        assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
11654        assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
11655    }
11656
11657    #[test]
11658    fn only_the_viewers_own_fork_can_be_pushed_to() {
11659        let pr = |owner: &str, name: &str| PrView {
11660            number: 9,
11661            url: String::new(),
11662            title: String::new(),
11663            body: String::new(),
11664            head_ref_name: "issue-7".into(),
11665            base_ref_name: "main".into(),
11666            state: "OPEN".into(),
11667            closing_issues_references: Vec::new(),
11668            is_cross_repository: true,
11669            head_repository_owner: Some(crate::model::Author {
11670                login: owner.into(),
11671            }),
11672            head_repository: Some(crate::model::HeadRepository { name: name.into() }),
11673        };
11674        let origin = "git@github.com:example/project.git";
11675
11676        assert_eq!(
11677            Some(PushTarget::Fork(Fork {
11678                owner: "Someone".into(),
11679                full_name: "Someone/project".into(),
11680                url: "git@github.com:Someone/project.git".into(),
11681            })),
11682            own_fork_target(&pr("Someone", "project"), "someone", origin)
11683        );
11684        assert_eq!(
11685            None,
11686            own_fork_target(&pr("other", "project"), "someone", origin)
11687        );
11688        assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
11689        assert_eq!(
11690            None,
11691            own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
11692        );
11693        let mut unnamed = pr("someone", "project");
11694        unnamed.head_repository_owner = None;
11695        assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
11696    }
11697
11698    /// `spar resume` and `spar checkin` on a pull request from the user's own
11699    /// fork check its branch out from the fork and push back there.
11700    #[cfg(unix)]
11701    #[test]
11702    fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
11703        let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
11704        test_git(
11705            &work,
11706            &[
11707                "push",
11708                "-q",
11709                fork.to_str().unwrap(),
11710                "issue-7:refs/heads/issue-7",
11711            ],
11712        );
11713        let published = test_git(&work, &["rev-parse", "issue-7"]);
11714        test_git(&work, &["checkout", "-q", "main"]);
11715        let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
11716        let pr = PrView {
11717            number: 31,
11718            url: String::new(),
11719            title: String::new(),
11720            body: String::new(),
11721            head_ref_name: "issue-7".into(),
11722            base_ref_name: "main".into(),
11723            state: "OPEN".into(),
11724            closing_issues_references: Vec::new(),
11725            is_cross_repository: true,
11726            head_repository_owner: None,
11727            head_repository: None,
11728        };
11729
11730        let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
11731
11732        assert_eq!("issue-7", branch);
11733        assert_eq!(
11734            published.trim(),
11735            test_git(&path, &["rev-parse", "HEAD"]).trim()
11736        );
11737        std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
11738        test_git(&path, &["commit", "-qam", "Answer a comment"]);
11739        repo.push_to(&path, &target, &branch, Some(published.trim()))
11740            .unwrap();
11741        assert_eq!(
11742            test_git(&path, &["rev-parse", "HEAD"]).trim(),
11743            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11744        );
11745        assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11746            .trim()
11747            .is_empty());
11748    }
11749
11750    #[cfg(unix)]
11751    #[test]
11752    fn with_forking_off_a_refused_push_says_why_and_how() {
11753        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
11754
11755        let err = repo
11756            .publish_new_branch(&work, "issue-7", "main")
11757            .unwrap_err()
11758            .to_string();
11759
11760        assert!(
11761            err.contains("Permission to example/project.git denied to someone."),
11762            "{err}"
11763        );
11764        assert!(err.contains("fork_when_denied = true"), "{err}");
11765        assert!(test_git(&fork, &["branch", "--list", "issue-7"])
11766            .trim()
11767            .is_empty());
11768        assert_eq!(
11769            WriteSummary {
11770                attempted: 1,
11771                failed: 1
11772            },
11773            repo.write_summary()
11774        );
11775    }
11776}
11777
11778#[cfg(test)]
11779mod comment_page_tests {
11780    use super::*;
11781
11782    #[test]
11783    fn a_single_merged_array_is_read() {
11784        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
11785        assert_eq!(2, pages.len());
11786        assert_eq!(Some(2), pages[1]["id"].as_i64());
11787    }
11788
11789    #[test]
11790    fn concatenated_pages_from_an_older_gh_are_read_too() {
11791        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
11792        assert_eq!(2, pages.len());
11793    }
11794
11795    /// A comment body containing a bracket pair used to split the payload into
11796    /// two invalid halves, so no state comment was found and a resume silently
11797    /// started from round one.
11798    #[test]
11799    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
11800        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
11801        let pages = parse_comment_pages(text);
11802        assert_eq!(2, pages.len(), "{pages:?}");
11803        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
11804    }
11805
11806    #[test]
11807    fn empty_output_is_no_comments_not_a_panic() {
11808        assert!(parse_comment_pages("").is_empty());
11809        assert!(parse_comment_pages("   ").is_empty());
11810        assert!(parse_comment_pages("[]").is_empty());
11811    }
11812
11813    #[test]
11814    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
11815        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
11816    }
11817
11818    #[test]
11819    fn a_write_postcheck_rejects_truncated_comment_pages() {
11820        let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
11821        assert!(
11822            error.to_string().contains("unexpected comment pages"),
11823            "{error}"
11824        );
11825    }
11826
11827    #[test]
11828    fn a_write_postcheck_rejects_empty_or_non_array_output() {
11829        assert!(try_parse_comment_pages("").is_err());
11830        assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
11831        assert!(try_parse_comment_pages("[]").is_ok());
11832    }
11833
11834    #[test]
11835    fn state_is_found_in_the_last_matching_comment() {
11836        let payload = |round: u32| {
11837            format!(
11838                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11839            )
11840        };
11841        let text = serde_json::to_string(&serde_json::json!([
11842            {"id": 1, "body": payload(1)},
11843            {"id": 2, "body": "looks good to me"},
11844            {"id": 3, "body": payload(5)},
11845        ]))
11846        .unwrap();
11847        let pages = parse_comment_pages(&text);
11848        let last = pages
11849            .iter()
11850            .rev()
11851            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
11852            .unwrap();
11853        assert_eq!(5, last.round);
11854    }
11855}
11856
11857#[cfg(test)]
11858mod linked_pr_tests {
11859    use super::*;
11860
11861    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
11862    /// It carries an id and a whole repository object per linked issue, and a
11863    /// parser that chokes on those reports "no pull request", which is the one
11864    /// answer that makes spar implement over the top of somebody's work.
11865    const REAL_PAYLOAD: &str = r#"[
11866      {"number":14252,"title":"fix: reject leading-dash branch names",
11867       "url":"https://github.com/cli/cli/pull/14252",
11868       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
11869         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11870         "url":"https://github.com/cli/cli/issues/14238"}]},
11871      {"number":14217,"title":"another change",
11872       "url":"https://github.com/cli/cli/pull/14217",
11873       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
11874         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11875         "url":"https://github.com/cli/cli/issues/9761"}]},
11876      {"number":14200,"title":"unlinked work",
11877       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
11878    ]"#;
11879
11880    #[test]
11881    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
11882        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
11883        assert_eq!(14252, pr.number);
11884        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
11885    }
11886
11887    #[test]
11888    fn the_right_pr_is_picked_out_of_several() {
11889        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
11890    }
11891
11892    #[test]
11893    fn an_issue_nobody_is_working_on_finds_nothing() {
11894        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
11895    }
11896
11897    #[test]
11898    fn an_unlinked_pr_is_never_matched() {
11899        // 14200 closes nothing, so no issue number should ever return it.
11900        for issue in [14200, 0, 1] {
11901            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
11902                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
11903            }
11904        }
11905    }
11906
11907    #[test]
11908    fn empty_or_broken_output_is_none_rather_than_a_panic() {
11909        assert!(find_linked_pr("", 1).is_none());
11910        assert!(find_linked_pr("[]", 1).is_none());
11911        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
11912        assert!(find_linked_pr("[{\"number\":", 1).is_none());
11913    }
11914
11915    /// "gh could not answer" and "nobody is working on it" are different
11916    /// answers, and only one of them means implement from scratch.
11917    ///
11918    /// The parse used to fold both into `None`, so an authentication failure or
11919    /// a rate limit read as an empty list and spar opened a second pull request
11920    /// for an issue somebody already had work open on.
11921    #[test]
11922    fn an_unreadable_list_is_not_an_empty_list() {
11923        assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
11924        assert!(linked_pr_rows("[{\"number\":").is_none());
11925        assert!(linked_pr_rows("").is_none());
11926
11927        let empty = linked_pr_rows("[]").expect("an empty list is an answer");
11928        assert!(empty.is_empty());
11929        assert!(pick_linked_pr(&empty, 14238).is_none());
11930
11931        let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
11932        assert_eq!(3, listed.len());
11933        assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
11934    }
11935
11936    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
11937    #[test]
11938    fn pr_view_reads_the_cross_repository_flag() {
11939        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
11940                       "baseRefName":"main","state":"OPEN",
11941                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
11942        let pr: PrView = serde_json::from_str(json).unwrap();
11943        assert!(pr.is_cross_repository);
11944        assert!(pr.is_open());
11945
11946        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
11947        assert!(
11948            !serde_json::from_str::<PrView>(&same_repo)
11949                .unwrap()
11950                .is_cross_repository
11951        );
11952    }
11953}
11954
11955#[cfg(test)]
11956mod min_number_tests {
11957    /// The floor is applied before the cap, which is the order that matters.
11958    /// spar takes the *lowest* numbered open items, so a repository with a tail
11959    /// of old issues would otherwise spend its whole run in the tail: the cap
11960    /// would be filled by the oldest items and the floor would never be
11961    /// reached. Filtering first is what makes the setting do anything.
11962    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
11963        let mut numbers: Vec<i64> = open.to_vec();
11964        numbers.sort_unstable();
11965        if min_number > 0 {
11966            numbers.retain(|n| *n >= min_number);
11967        }
11968        numbers.truncate(limit);
11969        numbers
11970    }
11971
11972    #[test]
11973    fn the_floor_is_applied_before_the_cap_not_after() {
11974        let open = [12, 13, 14, 480, 481, 482];
11975        assert_eq!(vec![480, 481], pick(&open, 2, 480));
11976        // Capping first would have returned the two oldest and then filtered
11977        // them all away, leaving nothing.
11978        assert!(!pick(&open, 2, 480).is_empty());
11979    }
11980
11981    #[test]
11982    fn no_floor_keeps_the_old_behaviour() {
11983        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
11984    }
11985
11986    #[test]
11987    fn the_floor_is_inclusive() {
11988        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
11989    }
11990
11991    #[test]
11992    fn a_floor_above_everything_open_yields_nothing() {
11993        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
11994    }
11995}
11996
11997#[cfg(test)]
11998mod review_record_tests {
11999    use super::*;
12000    use serde_json::json;
12001
12002    #[test]
12003    fn prune_state_knows_which_files_belong_to_a_pull_request() {
12004        assert_eq!(Some(12), state_file_number("pr-12.json"));
12005        assert_eq!(Some(12), state_file_number("review-12.json"));
12006        assert_eq!(Some(12), state_file_number("checkin-12.json"));
12007        for shared in [
12008            "spend.json",
12009            "implementors.json",
12010            "contested.json",
12011            "listen.json",
12012            "pr-.json",
12013            "pr-12.json.tmp",
12014            "pr-x.json",
12015        ] {
12016            assert_eq!(None, state_file_number(shared), "{shared}");
12017        }
12018    }
12019
12020    #[test]
12021    fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
12022        let reviews = vec![
12023            json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
12024            json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
12025        ];
12026        assert!(has_viewer_review(&reviews, "me", &["the body"], "aaa"));
12027        assert!(!has_viewer_review(&reviews, "me", &["other"], "aaa"));
12028        assert!(!has_viewer_review(&reviews, "alice", &["the body"], "aaa"));
12029    }
12030
12031    /// A review with line comments goes up with a brief body, and the same
12032    /// review refused its lines goes up with the full one. A rerun after either
12033    /// landed would otherwise post a second review.
12034    #[test]
12035    fn a_review_that_landed_with_either_body_is_not_sent_again() {
12036        let brief = vec![
12037            json!({"user": {"login": "me"}, "body": "brief", "commit_id": "aaa", "state": "COMMENTED"}),
12038        ];
12039        let full = vec![
12040            json!({"user": {"login": "me"}, "body": "full", "commit_id": "aaa", "state": "CHANGES_REQUESTED"}),
12041        ];
12042        for landed in [&brief, &full] {
12043            assert!(has_viewer_review(landed, "me", &["brief", "full"], "aaa"));
12044            assert!(has_viewer_review(landed, "me", &["full", "brief"], "aaa"));
12045            assert!(!has_viewer_review(landed, "me", &["brief", "full"], "bbb"));
12046        }
12047        assert!(!has_viewer_review(&brief, "me", &["full"], "aaa"));
12048        assert!(!has_viewer_review(&full, "me", &[], "aaa"));
12049    }
12050
12051    /// A clean review says the same thing on every commit. Taking the approval
12052    /// of an older commit for this one left the new head unapproved while the
12053    /// record said it was.
12054    #[test]
12055    fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
12056        let older = vec![
12057            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
12058        ];
12059        assert!(!has_viewer_review(&older, "me", &["the body"], "bbb"));
12060        let dismissed = vec![
12061            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
12062        ];
12063        assert!(!has_viewer_review(&dismissed, "me", &["the body"], "bbb"));
12064    }
12065
12066    #[test]
12067    fn a_response_splits_into_status_headers_and_body() {
12068        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";
12069        let resp = parse_api_response(text).expect("a response");
12070        assert_eq!(200, resp.status);
12071        assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
12072        assert_eq!(
12073            Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
12074            resp.headers.get("last-modified")
12075        );
12076        assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
12077
12078        let not_modified =
12079            parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
12080        assert_eq!(304, not_modified.status);
12081        assert!(not_modified.body.is_empty());
12082
12083        assert_eq!(None, parse_api_response(""));
12084        assert_eq!(None, parse_api_response("gh: command not found\n"));
12085    }
12086
12087    #[test]
12088    fn review_requests_are_read_off_the_timeline_with_who_and_when() {
12089        let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
12090            {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
12091            {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
12092            {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
12093            {}
12094        ]}}}}})
12095        .to_string();
12096        let made = parse_review_requests(&text).expect("requests");
12097        assert_eq!(2, made.len());
12098        assert_eq!("2026-10-20T10:00:00Z", made[0].at);
12099        assert_eq!("viewer", made[0].who.login);
12100        assert!(made[0].who.slug.is_empty());
12101        assert_eq!("2026-10-20T11:00:00Z", made[1].at);
12102        assert!(made[1].who.login.is_empty());
12103        assert_eq!("example/ours", made[1].who.slug);
12104
12105        let none =
12106            json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
12107        assert_eq!(
12108            Some(0),
12109            parse_review_requests(&none.to_string()).map(|m| m.len())
12110        );
12111        assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
12112        assert!(parse_review_requests("gh: not json").is_none());
12113    }
12114
12115    #[test]
12116    fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
12117        let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
12118        let error = crate::error::SparError::call_failed("gh: boom");
12119        assert!(
12120            reconcile_review_post(5, &["the body"], "me", "aaa", error.clone(), Ok(landed)).is_ok()
12121        );
12122
12123        let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
12124        let err = reconcile_review_post(5, &["the body"], "me", "aaa", error.clone(), Ok(missing))
12125            .unwrap_err();
12126        assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
12127
12128        let unreadable = Err(crate::error::SparError::new("no network"));
12129        let err =
12130            reconcile_review_post(5, &["the body"], "me", "aaa", error, unreadable).unwrap_err();
12131        assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
12132        assert!(err.to_string().contains("could not be verified"), "{err}");
12133    }
12134    #[test]
12135    fn a_review_with_line_comments_names_the_new_side_and_a_range_only_when_it_has_one() {
12136        let comments = vec![
12137            InlineComment {
12138                path: "src/a.rs".into(),
12139                line: 12,
12140                start_line: None,
12141                body: "one".into(),
12142            },
12143            InlineComment {
12144                path: "src/b.rs".into(),
12145                line: 9,
12146                start_line: Some(4),
12147                body: "two".into(),
12148            },
12149        ];
12150        assert_eq!(
12151            json!({
12152                "commit_id": "abc",
12153                "event": "COMMENT",
12154                "body": "the body",
12155                "comments": [
12156                    {"path": "src/a.rs", "line": 12, "side": "RIGHT", "body": "one"},
12157                    {"path": "src/b.rs", "line": 9, "side": "RIGHT", "start_line": 4,
12158                     "start_side": "RIGHT", "body": "two"},
12159                ],
12160            }),
12161            review_payload("abc", "COMMENT", "the body", &comments)
12162        );
12163    }
12164
12165    #[test]
12166    fn the_new_side_of_each_hunk_is_read_and_its_lines_are_never_taken_for_headers() {
12167        let diff = r#"diff --git a/src/a.rs b/src/a.rs
12168index 1111111..2222222 100644
12169--- a/src/a.rs
12170+++ b/src/a.rs
12171@@ -1,3 +1,4 @@ fn x
12172 one
12173+++ an added line that looks like a header
12174 two
12175 three
12176@@ -20 +21,2 @@
12177-old
12178+new
12179+more
12180@@ -30,1 +32,0 @@
12181-gone
12182diff --git a/gone.rs b/gone.rs
12183deleted file mode 100644
12184--- a/gone.rs
12185+++ /dev/null
12186@@ -1,2 +0,0 @@
12187-a
12188-b
12189diff --git a/old.rs b/renamed.rs
12190similarity index 90%
12191rename from old.rs
12192rename to renamed.rs
12193--- a/old.rs
12194+++ b/renamed.rs
12195@@ -5,0 +6,1 @@
12196+inserted
12197diff --git a/with space.rs b/with space.rs
12198--- a/with space.rs	
12199+++ b/with space.rs	
12200@@ -2 +2 @@
12201-x
12202+y
12203diff --git "a/sp\tace.rs" "b/sp\tace.rs"
12204--- "a/sp\tace.rs"
12205+++ "b/sp\tace.rs"
12206@@ -1 +1 @@
12207-x
12208+y
12209"#;
12210        let hunks = new_side_hunks(diff);
12211        assert_eq!(
12212            Hunks::from([
12213                ("renamed.rs".to_string(), vec![(6, 6)]),
12214                ("src/a.rs".to_string(), vec![(1, 4), (21, 22)]),
12215                ("with space.rs".to_string(), vec![(2, 2)]),
12216            ]),
12217            hunks
12218        );
12219    }
12220
12221    #[test]
12222    fn only_a_refusal_is_final_enough_to_send_a_review_again() {
12223        let error = |text: &str| crate::error::SparError::new(text);
12224        assert!(refused(&error(
12225            "command failed (1): gh api\ngh: Validation Failed (HTTP 422)"
12226        )));
12227        assert!(!refused(&error("gh: Bad Gateway (HTTP 502)")));
12228        assert!(!refused(&error("gh: HTTP 504: Gateway Timeout")));
12229        assert!(!refused(&error("command timed out after 300s")));
12230    }
12231
12232    #[test]
12233    fn a_hunk_header_with_its_counts_left_out_counts_one() {
12234        assert_eq!(Some(((20, 1), (21, 2))), hunk_header("@@ -20 +21,2 @@"));
12235        assert_eq!(Some(((1, 3), (1, 1))), hunk_header("@@ -1,3 +1 @@ fn x"));
12236        assert_eq!(None, hunk_header("@@@ -1,3 -1,3 +1,4 @@@"));
12237        assert_eq!(None, hunk_header(" @@ -1 +1 @@"));
12238    }
12239}