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/// What one issue says about who it is assigned to, as both assignment
1268/// queries ask for it. The last twenty assignments are plenty: only the newest
1269/// one of the viewer matters.
1270const ASSIGNMENT_FIELDS: &str = "
1271        number
1272        title
1273        assignees(first: 100) { nodes { login } }
1274        timelineItems(itemTypes: [ASSIGNED_EVENT], last: 20) {
1275          nodes {
1276            ... on AssignedEvent {
1277              createdAt
1278              assignee {
1279                ... on User { login }
1280                ... on Bot { login }
1281                ... on Mannequin { login }
1282              }
1283            }
1284          }
1285        }";
1286
1287/// The open issues assigned to one login, most recently updated first. A
1288/// hundred is far more than anybody is assigned at once, and the newest
1289/// updates, which are where a new assignment shows, come first.
1290fn assigned_issues_query() -> String {
1291    format!(
1292        "query($owner: String!, $repo: String!, $login: String!) {{
1293  repository(owner: $owner, name: $repo) {{
1294    issues(states: OPEN, filterBy: {{assignee: $login}}, first: 100, orderBy: {{field: UPDATED_AT, direction: DESC}}) {{
1295      nodes {{{ASSIGNMENT_FIELDS}
1296      }}
1297    }}
1298  }}
1299}}"
1300    )
1301}
1302
1303/// The same for one issue, open or not.
1304fn issue_assignments_query() -> String {
1305    format!(
1306        "query($owner: String!, $repo: String!, $number: Int!) {{
1307  repository(owner: $owner, name: $repo) {{
1308    issue(number: $number) {{{ASSIGNMENT_FIELDS}
1309    }}
1310  }}
1311}}"
1312    )
1313}
1314
1315/// One issue node either query returned. None without a number.
1316fn assigned_issue(node: &Value) -> Option<crate::model::AssignedIssue> {
1317    let text_at = |node: &Value, path: &str| {
1318        node.pointer(path)
1319            .and_then(Value::as_str)
1320            .unwrap_or_default()
1321            .to_string()
1322    };
1323    let nodes = |path: &str| {
1324        node.pointer(path)
1325            .and_then(Value::as_array)
1326            .cloned()
1327            .unwrap_or_default()
1328    };
1329    Some(crate::model::AssignedIssue {
1330        number: node.pointer("/number")?.as_i64()?,
1331        title: text_at(node, "/title"),
1332        assignees: nodes("/assignees/nodes")
1333            .iter()
1334            .map(|a| text_at(a, "/login"))
1335            .filter(|login| !login.is_empty())
1336            .collect(),
1337        // An assignment of somebody GitHub no longer knows names nobody, and
1338        // one without a time cannot be compared with anything.
1339        assigned: nodes("/timelineItems/nodes")
1340            .iter()
1341            .map(|e| (text_at(e, "/createdAt"), text_at(e, "/assignee/login")))
1342            .filter(|(at, who)| !at.is_empty() && !who.is_empty())
1343            .collect(),
1344    })
1345}
1346
1347/// The issues `assigned_issues_query` returned. None when the answer is not
1348/// that shape, which is a failed sweep rather than nobody assigned anything.
1349pub fn parse_assigned_issues(text: &str) -> Option<Vec<crate::model::AssignedIssue>> {
1350    let v: Value = serde_json::from_str(text).ok()?;
1351    let nodes = v.pointer("/data/repository/issues/nodes")?.as_array()?;
1352    Some(nodes.iter().filter_map(assigned_issue).collect())
1353}
1354
1355/// The issue `issue_assignments_query` returned. None when the answer is not
1356/// that shape.
1357pub fn parse_issue_assignments(text: &str) -> Option<crate::model::AssignedIssue> {
1358    let v: Value = serde_json::from_str(text).ok()?;
1359    assigned_issue(v.pointer("/data/repository/issue")?)
1360}
1361
1362/// The pull request a per item state file is about: `pr-12.json`,
1363/// `review-12.json`, `checkin-12.json`. None for anything else, so the shared
1364/// files beside them, `spend.json` and the rest, are never touched.
1365pub(crate) fn state_file_number(name: &str) -> Option<i64> {
1366    let stem = name.strip_suffix(".json")?;
1367    ["pr-", "review-", "checkin-"]
1368        .iter()
1369        .find_map(|prefix| stem.strip_prefix(prefix))
1370        .filter(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
1371        .and_then(|digits| digits.parse().ok())
1372}
1373
1374/// Whether the viewer has already submitted this review: exactly one of these
1375/// bodies, on this commit, and still standing. A clean review reads the same on
1376/// every commit, so a body alone would take an approval of an older commit for
1377/// this one; and a dismissed review was withdrawn, so it is not this one either.
1378///
1379/// More than one body because a review with line comments goes up with a
1380/// shorter one than the same review without them.
1381fn has_viewer_review(reviews: &[Value], viewer: &str, bodies: &[&str], commit: &str) -> bool {
1382    reviews.iter().any(|review| {
1383        let field = |name: &str| review.get(name).and_then(Value::as_str).unwrap_or("");
1384        let standing = !matches!(
1385            field("state").to_uppercase().as_str(),
1386            "DISMISSED" | "PENDING"
1387        );
1388        review
1389            .get("user")
1390            .and_then(|u| u.get("login"))
1391            .and_then(Value::as_str)
1392            .is_some_and(|login| crate::comments::same_login(login, viewer))
1393            && bodies.contains(&field("body"))
1394            && field("commit_id").eq_ignore_ascii_case(commit)
1395            && standing
1396    })
1397}
1398
1399fn reconcile_review_post(
1400    number: i64,
1401    bodies: &[&str],
1402    viewer: &str,
1403    commit: &str,
1404    post_error: crate::error::SparError,
1405    reviews: Result<Vec<Value>>,
1406) -> Result<()> {
1407    match reviews {
1408        Ok(reviews) if has_viewer_review(&reviews, viewer, bodies, commit) => Ok(()),
1409        Ok(_) => Err(post_error),
1410        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1411            "could not submit a review on #{number}. {} The result could not be verified: {}",
1412            post_error.last_line(),
1413            read_error.last_line()
1414        ))),
1415    }
1416}
1417
1418/// The body of a review that carries line comments, for `gh api --input -`.
1419///
1420/// Every comment is on the new side of the diff, which is the only side a
1421/// finding about the branch is ever about.
1422fn review_payload(commit: &str, event: &str, body: &str, comments: &[InlineComment]) -> Value {
1423    let comments: Vec<Value> = comments
1424        .iter()
1425        .map(|c| {
1426            let mut row = serde_json::json!({
1427                "path": c.path,
1428                "line": c.line,
1429                "side": "RIGHT",
1430                "body": c.body,
1431            });
1432            if let Some(start) = c.start_line {
1433                row["start_line"] = serde_json::json!(start);
1434                row["start_side"] = serde_json::json!("RIGHT");
1435            }
1436            row
1437        })
1438        .collect();
1439    serde_json::json!({
1440        "commit_id": commit,
1441        "event": event,
1442        "body": body,
1443        "comments": comments,
1444    })
1445}
1446
1447/// Whether GitHub looked at a write and refused it, rather than failing to
1448/// answer. A refusal is final, so the same write changed is safe to send; a
1449/// timeout or a gateway error says nothing about whether the first one landed.
1450fn refused(e: &crate::error::SparError) -> bool {
1451    e.to_string().contains("(HTTP 422)")
1452}
1453
1454/// The lines of each file a pull request's diff shows on its new side, as
1455/// first and last line of each hunk. GitHub takes a line comment only on one
1456/// of these, and refuses the whole review over one that is not.
1457pub type Hunks = BTreeMap<String, Vec<(u32, u32)>>;
1458
1459/// Read the hunks out of a unified diff.
1460///
1461/// A hunk's lines are counted off against its header rather than recognised by
1462/// their first character, so an added line that itself begins `+++ ` or `@@ `
1463/// is content and not the start of something. A path git had to quote is left
1464/// out: what is between the quotes is escaped, not a path GitHub would match.
1465fn new_side_hunks(diff: &str) -> Hunks {
1466    let mut out = Hunks::new();
1467    let mut path: Option<String> = None;
1468    let (mut old_left, mut new_left) = (0u32, 0u32);
1469    for line in diff.lines() {
1470        if old_left > 0 || new_left > 0 {
1471            match line.as_bytes().first() {
1472                Some(b'+') => new_left = new_left.saturating_sub(1),
1473                Some(b'-') => old_left = old_left.saturating_sub(1),
1474                Some(b'\\') => {}
1475                _ => {
1476                    old_left = old_left.saturating_sub(1);
1477                    new_left = new_left.saturating_sub(1);
1478                }
1479            }
1480            continue;
1481        }
1482        if line.starts_with("diff --git ") {
1483            path = None;
1484        } else if let Some(rest) = line.strip_prefix("+++ ") {
1485            // git ends the line with a tab when the path has a space in it,
1486            // so that a patch tool can tell where the name stops.
1487            let rest = if rest.contains(' ') {
1488                rest.strip_suffix('\t').unwrap_or(rest)
1489            } else {
1490                rest
1491            };
1492            path = rest
1493                .strip_prefix("b/")
1494                .filter(|p| !p.is_empty())
1495                .map(str::to_string);
1496        } else if let Some(((_, old_len), (start, len))) = hunk_header(line) {
1497            old_left = old_len;
1498            new_left = len;
1499            if let (Some(path), true) = (&path, len > 0 && start > 0) {
1500                out.entry(path.clone())
1501                    .or_default()
1502                    .push((start, start + len - 1));
1503            }
1504        }
1505    }
1506    out
1507}
1508
1509/// `@@ -a,b +c,d @@`, as `((a, b), (c, d))`. A count left out is one.
1510fn hunk_header(line: &str) -> Option<((u32, u32), (u32, u32))> {
1511    let ranges = line.strip_prefix("@@ -")?.split_once(" @@")?.0;
1512    let (old, new) = ranges.split_once(" +")?;
1513    let range = |s: &str| -> Option<(u32, u32)> {
1514        match s.split_once(',') {
1515            Some((start, len)) => Some((start.parse().ok()?, len.parse().ok()?)),
1516            None => Some((s.parse().ok()?, 1)),
1517        }
1518    };
1519    Some((range(old)?, range(new)?))
1520}
1521
1522fn has_exact_comment(comments: &[Value], body: &str) -> bool {
1523    comments.iter().any(|comment| {
1524        comment
1525            .get("body")
1526            .and_then(Value::as_str)
1527            .is_some_and(|seen| seen == body)
1528    })
1529}
1530
1531fn reconcile_comment_post(
1532    number: i64,
1533    body: &str,
1534    post_error: crate::error::SparError,
1535    comments: Result<Vec<Value>>,
1536) -> Result<()> {
1537    match comments {
1538        Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
1539        Ok(_) => Err(post_error),
1540        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1541            "could not comment on #{number}. {} The result could not be verified: {}",
1542            post_error.last_line(),
1543            read_error.last_line()
1544        ))),
1545    }
1546}
1547
1548fn reconcile_issue_edit(
1549    number: i64,
1550    wanted: &str,
1551    edit_error: crate::error::SparError,
1552    observed: Result<String>,
1553) -> Result<()> {
1554    match observed {
1555        Ok(body) if body == wanted => Ok(()),
1556        Ok(_) => Err(spar_err!(
1557            "could not rewrite the body of #{number}. {}",
1558            edit_error.last_line()
1559        )),
1560        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1561            "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1562            edit_error.last_line(),
1563            read_error.last_line()
1564        ))),
1565    }
1566}
1567
1568fn issue_url_has_number(url: &str) -> bool {
1569    url.trim()
1570        .rsplit('/')
1571        .next()
1572        .and_then(|tail| tail.parse::<i64>().ok())
1573        .is_some_and(|number| number > 0)
1574}
1575
1576fn reconcile_issue_creation(
1577    title: &str,
1578    created: Result<String>,
1579    found: Result<Option<ExistingIssue>>,
1580) -> Result<String> {
1581    match (created, found) {
1582        (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1583        (_, Ok(Some(issue))) => Ok(issue.url),
1584        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1585            "issue creation reported success but no matching issue was found for {title:?}"
1586        ))),
1587        (Err(create), Ok(None)) => Err(spar_err!(
1588            "could not file issue {title:?}. {}",
1589            create.last_line()
1590        )),
1591        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1592            "issue creation reported success for {title:?}, but it could not be verified. {}",
1593            check.last_line()
1594        ))),
1595        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1596            "could not file issue {title:?}. {} The result could not be verified: {}",
1597            create.last_line(),
1598            check.last_line()
1599        ))),
1600    }
1601}
1602
1603fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1604    if output.trim().is_empty() {
1605        return Ok(None);
1606    }
1607    for line in output.lines() {
1608        let mut fields = line.split_whitespace();
1609        let oid = fields.next().unwrap_or_default();
1610        let name = fields.next().unwrap_or_default();
1611        if name == remote_ref && !oid.is_empty() {
1612            return Ok(Some(oid.to_string()));
1613        }
1614    }
1615    Err(spar_err!(
1616        "origin returned an unexpected ref listing for {remote_ref}"
1617    ))
1618}
1619
1620fn reconcile_failed_split_push(
1621    branch: &str,
1622    push_error: crate::error::SparError,
1623    local: Result<String>,
1624    remote: Result<String>,
1625) -> std::result::Result<(), SplitPushError> {
1626    let remote_ref = format!("refs/heads/{branch}");
1627    match (local, remote) {
1628        (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1629            Ok(Some(oid)) if oid == local.trim() => Ok(()),
1630            Ok(_) => Err(SplitPushError::new(
1631                format!(
1632                    "could not create origin/{branch}. {} The remote branch is absent or points \
1633                     somewhere else. Nothing was overwritten.",
1634                    push_error.last_line()
1635                ),
1636                false,
1637            )),
1638            Err(check) => Err(SplitPushError::new(
1639                format!(
1640                    "could not confirm whether origin/{branch} was created. {} The remote result \
1641                     could not be verified: {}",
1642                    push_error.last_line(),
1643                    check.last_line()
1644                ),
1645                true,
1646            )),
1647        },
1648        (local, remote) => {
1649            let check = match (local, remote) {
1650                (Err(local), Err(remote)) => format!(
1651                    "the local commit could not be read: {}; origin could not be read: {}",
1652                    local.last_line(),
1653                    remote.last_line()
1654                ),
1655                (Err(local), _) => {
1656                    format!("the local commit could not be read: {}", local.last_line())
1657                }
1658                (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1659                _ => unreachable!(),
1660            };
1661            Err(SplitPushError::new(
1662                format!(
1663                    "could not confirm whether origin/{branch} was created. {} The result could \
1664                     not be verified because {check}",
1665                    push_error.last_line()
1666                ),
1667                true,
1668            ))
1669        }
1670    }
1671}
1672
1673impl Repo {
1674    pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1675        let root =
1676            std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1677        // A linked worktree has a `.git` file rather than a directory, and a
1678        // bare-ish layout can have neither, so ask git instead of guessing.
1679        let inside = proc::run_str(
1680            &["git", "rev-parse", "--is-inside-work-tree"],
1681            &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1682        )
1683        .unwrap_or_default();
1684        if inside.trim() != "true" {
1685            bail!("not a git repository: {}", root.display());
1686        }
1687        let repo = Self {
1688            root,
1689            style: cfg.style.clone(),
1690            branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1691            state_store: cfg.loop_cfg.state_store,
1692            followups: cfg.loop_cfg.followups,
1693            drafts: cfg.loop_cfg.drafts,
1694            fork_when_denied: cfg.loop_cfg.fork_when_denied,
1695            setup: cfg.loop_cfg.setup.clone(),
1696            isolate_reads: cfg.loop_cfg.worktrees,
1697            keep_worktrees: cfg.loop_cfg.keep_worktrees,
1698            fork: OnceLock::new(),
1699            viewer: OnceLock::new(),
1700            checkpoints: Mutex::new(BTreeMap::new()),
1701            writes: WriteStats::default(),
1702        };
1703        repo.self_exclude();
1704        Ok(repo)
1705    }
1706
1707    /// Keep spar's own scratch directories out of the target repo's
1708    /// `git status`.
1709    ///
1710    /// Written to `.git/info/exclude`, never to a tracked `.gitignore`: this is
1711    /// somebody else's repository and spar has no business committing to it.
1712    /// Best effort and silent on failure, because a read-only git directory is
1713    /// not a reason to abandon a run.
1714    fn self_exclude(&self) {
1715        let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1716        let git_dir = git_dir.trim();
1717        if git_dir.is_empty() {
1718            return;
1719        }
1720        let path = Path::new(git_dir).join("info").join("exclude");
1721        let existing = std::fs::read_to_string(&path).unwrap_or_default();
1722
1723        // The marker too. It is written into whichever directory the call ran
1724        // in, which for a shared checkout is the repository root, and an
1725        // untracked file there is one `git add -A` away from being committed.
1726        // The error names its path, which is how a person is meant to find it.
1727        let wanted = [
1728            format!("/{WORKTREE_DIR}/"),
1729            format!("/{STATE_DIR}/"),
1730            format!("{RECOVERY_MARKER_PREFIX}*"),
1731        ];
1732        let missing: Vec<&String> = wanted
1733            .iter()
1734            .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1735            .collect();
1736        if missing.is_empty() {
1737            return;
1738        }
1739
1740        use std::io::Write;
1741        if let Some(parent) = path.parent() {
1742            let _ = std::fs::create_dir_all(parent);
1743        }
1744        let mut block = String::new();
1745        if !existing.is_empty() && !existing.ends_with('\n') {
1746            block.push('\n');
1747        }
1748        block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1749        for line in missing {
1750            block.push_str(line);
1751            block.push('\n');
1752        }
1753        if let Ok(mut file) = std::fs::OpenOptions::new()
1754            .create(true)
1755            .append(true)
1756            .open(&path)
1757        {
1758            let _ = file.write_all(block.as_bytes());
1759        }
1760    }
1761
1762    pub fn root(&self) -> &Path {
1763        &self.root
1764    }
1765
1766    pub(crate) fn write_summary(&self) -> WriteSummary {
1767        WriteSummary {
1768            attempted: self.writes.attempted.load(Ordering::Relaxed),
1769            failed: self.writes.failed.load(Ordering::Relaxed),
1770        }
1771    }
1772
1773    pub(crate) fn record_write<T, E>(
1774        &self,
1775        result: std::result::Result<T, E>,
1776    ) -> std::result::Result<T, E> {
1777        self.record_write_outcome(result.is_err());
1778        result
1779    }
1780
1781    pub(crate) fn record_failed_write<T, E>(
1782        &self,
1783        result: std::result::Result<T, E>,
1784    ) -> std::result::Result<T, E> {
1785        if result.is_err() {
1786            self.record_write_outcome(true);
1787        }
1788        result
1789    }
1790
1791    fn record_write_outcome(&self, failed: bool) {
1792        self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1793        if failed {
1794            self.writes.failed.fetch_add(1, Ordering::Relaxed);
1795        }
1796    }
1797
1798    // -- gates ------------------------------------------------------------
1799
1800    /// Scrub, then verify. A leak here reaches GitHub, so it is a hard error
1801    /// rather than a warning: silent partial compliance is how a style rule
1802    /// erodes over a long run.
1803    pub fn clean(&self, text: &str) -> Result<String> {
1804        let out = style::scrub(text, &self.style);
1805        let bad = style::violations(&out, &self.style);
1806        if !bad.is_empty() {
1807            bail!(
1808                "style gate could not clean text ({}): {}",
1809                bad.join(", "),
1810                style::clip(&out, 300)
1811            );
1812        }
1813        Ok(out)
1814    }
1815
1816    /// Clean, and hold to a length budget. For anything a model wrote.
1817    pub fn clean_body(&self, text: &str) -> Result<String> {
1818        self.clean(&style::body(text, &self.style))
1819    }
1820
1821    /// The same, with an issue's far larger budget and its exemption for code.
1822    pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1823        self.clean(&style::issue_body(text, &self.style))
1824    }
1825
1826    /// The single transform every outbound title goes through.
1827    ///
1828    /// Scrub first, clip second, and never the other way round. Clipping first
1829    /// lets the scrub lengthen the result past the budget (an em dash becomes
1830    /// two characters), so a second pass would clip again and produce a
1831    /// different string. That broke follow-up deduplication silently: the
1832    /// lookup searched for one title while GitHub had stored another, no match
1833    /// was ever found, and a fresh duplicate issue was filed every review
1834    /// round. Doing it in this order makes the transform idempotent, which the
1835    /// tests assert.
1836    pub fn clean_title(&self, text: &str) -> Result<String> {
1837        Ok(style::title(&self.clean(text)?, &self.style))
1838    }
1839
1840    pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1841        let title = self.record_failed_write(self.clean_title(text))?;
1842        if title.trim().is_empty() {
1843            return self.record_failed_write(Err(spar_err!(
1844                "nothing left of the title after cleaning it"
1845            )));
1846        }
1847        Ok(title)
1848    }
1849
1850    pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1851        if self.followups == Followups::Issues {
1852            self.clean_nonempty_title_for_write(text)
1853        } else {
1854            self.clean_title(text)
1855        }
1856    }
1857
1858    // -- git --------------------------------------------------------------
1859
1860    fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1861        ExecOpts::new()
1862            .cwd(cwd.unwrap_or(&self.root))
1863            .check(check)
1864            .timeout_secs(600)
1865    }
1866
1867    pub fn git(&self, args: &[&str]) -> Result<String> {
1868        self.git_at(None, args)
1869    }
1870
1871    pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1872        let argv = git_without_maintenance_argv(args);
1873        proc::run(&argv, &self.git_opts(cwd, true))
1874    }
1875
1876    /// Run a parent-side Git operation without inherited background helpers.
1877    ///
1878    /// Editing calls are untrusted. Once one returns, status, staging, and
1879    /// committing happen in this process, so an inherited fsmonitor or automatic
1880    /// maintenance command must not become a way to execute outside its sandbox.
1881    fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1882        let argv = git_without_automation_argv(args);
1883        proc::run(
1884            &argv,
1885            &self.git_opts(Some(cwd), true).stop_descendants(true),
1886        )
1887    }
1888
1889    fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1890        let argv = git_without_automation_argv(args);
1891        proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1892            .map(|output| output.ok())
1893    }
1894
1895    /// Run git, tolerating failure. Returns whatever landed on stdout.
1896    pub fn git_try(&self, args: &[&str]) -> String {
1897        self.git_try_at(None, args)
1898    }
1899
1900    pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1901        let argv = git_without_maintenance_argv(args);
1902        proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1903    }
1904
1905    /// The base branch the remote actually points at, rather than assuming
1906    /// `main`. Falls back to the configured value when there is no origin.
1907    pub fn default_branch(&self, configured: &str) -> String {
1908        let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1909        match refname.trim().rsplit('/').next() {
1910            Some(name) if !name.is_empty() => name.to_string(),
1911            _ => configured.to_string(),
1912        }
1913    }
1914
1915    // -- branch naming and ownership --------------------------------------
1916    //
1917    // Branch names default to `issue-N`, which is exactly what a person would
1918    // name a branch by hand. Ownership therefore cannot be inferred from the
1919    // name, so every branch spar creates is recorded and cleanup only ever
1920    // touches what is in that record.
1921
1922    pub fn branch_for_issue(&self, issue: i64) -> String {
1923        format!("{}issue-{issue}", self.branch_prefix)
1924    }
1925
1926    pub fn branch_for_pr(&self, number: i64) -> String {
1927        format!("{}pr-{number}", self.branch_prefix)
1928    }
1929
1930    /// One part of a split, numbered from 1 within its parent.
1931    ///
1932    /// Its own namespace rather than `issue-N`, because the parts of a split
1933    /// pull request have no issue of their own and would otherwise collide with
1934    /// the branch of the issue that happens to share the parent's number.
1935    ///
1936    /// The name a part is tried on first. `worktree_for_split` may end up on a
1937    /// suffixed one, because this name is not free forever.
1938    pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1939        format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1940    }
1941
1942    fn ledger_path(&self) -> PathBuf {
1943        self.root.join(STATE_DIR).join("branches.json")
1944    }
1945
1946    pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1947        std::fs::read_to_string(self.ledger_path())
1948            .ok()
1949            .and_then(|text| serde_json::from_str(&text).ok())
1950            .unwrap_or_default()
1951    }
1952
1953    pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1954        let mut data = self.known_branches();
1955        data.insert(
1956            branch.to_string(),
1957            BranchRecord {
1958                kind: kind.to_string(),
1959                number,
1960            },
1961        );
1962        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1963            logdim!("could not record branch {branch}: {e}");
1964        }
1965    }
1966
1967    pub fn forget_branch(&self, branch: &str) {
1968        let mut data = self.known_branches();
1969        if data.remove(branch).is_none() {
1970            return;
1971        }
1972        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1973            logdim!("could not update the branch record: {e}");
1974        }
1975    }
1976
1977    // -- worktrees --------------------------------------------------------
1978
1979    fn worktree_path(&self, name: &str) -> PathBuf {
1980        self.root.join(WORKTREE_DIR).join(name)
1981    }
1982
1983    /// Run `[loop] setup` in a worktree spar has just created.
1984    ///
1985    /// Before anything else looks at the tree, so the baseline an editing call
1986    /// is measured against already holds what setup wrote, and a setup that
1987    /// fails costs no agent call. The worktree is kept as it is: what an
1988    /// install writes is build output, which the next run rebuilds over.
1989    fn set_up_worktree(&self, path: &Path, label: &str) -> Result<()> {
1990        let argv = self.setup.argv();
1991        if argv.is_empty() {
1992            return Ok(());
1993        }
1994        let command = self.setup.describe();
1995        logdim!("setting up the {label}: {command}");
1996        let out = proc::exec(
1997            &argv,
1998            &ExecOpts::new()
1999                .cwd(path)
2000                .check(false)
2001                .timeout_secs(SETUP_TIMEOUT_SECS)
2002                .stop_descendants(true),
2003        )
2004        .map_err(|e| spar_err!("could not run the setup `{command}` for the {label}: {e}"))?;
2005        if !out.ok() {
2006            let text = format!("{}\n{}", out.stdout.trim_end(), out.stderr.trim_end());
2007            bail!(
2008                "the setup `{command}` failed in the {label} at {}, so no agent was asked to \
2009                 work in it.\n{}",
2010                path.display(),
2011                style::clip(text.trim(), 4000)
2012            );
2013        }
2014        Ok(())
2015    }
2016
2017    /// Isolate an issue so a failed run cannot poison the next one's base.
2018    pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
2019        let branch = self.branch_for_issue(issue);
2020        let path = self.worktree_path(&format!("issue-{issue}"));
2021
2022        self.refuse_issue_branch_rebuild(issue, base)?;
2023        self.refuse_dirty_worktree(
2024            &path,
2025            &format!("worktree for issue #{issue}"),
2026            Disposable::BuildOutput,
2027        )?;
2028
2029        if !self.branch_deletion_is_safe(&branch)? {
2030            bail!(
2031                "the existing branch {branch} has a tip or reflog-only commit that no surviving \
2032                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2033                 before retrying."
2034            );
2035        }
2036
2037        if !self.remove_worktree_at(&path)? {
2038            bail!(
2039                "the existing worktree for issue #{issue} could not be removed safely. Its \
2040                 branch was kept."
2041            );
2042        }
2043        if !self.delete_branch_if_safe(&branch)? {
2044            bail!(
2045                "the existing branch {branch} changed or remained checked out while its \
2046                 worktree was being rebuilt. It was kept."
2047            );
2048        }
2049
2050        if let Some(parent) = path.parent() {
2051            std::fs::create_dir_all(parent)
2052                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2053        }
2054
2055        let path_str = path.display().to_string();
2056        let remote_start = format!("origin/{base}");
2057        let created = self
2058            .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
2059            .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
2060
2061        // Recorded on both paths: an unrecorded branch is one cleanup will
2062        // never remove, and the fallback creates a branch just the same.
2063        created.map_err(|e| {
2064            spar_err!(
2065                "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
2066                 and does `origin` exist?",
2067                e.last_line()
2068            )
2069        })?;
2070        self.record_branch(&branch, "issue", issue);
2071        self.set_up_worktree(&path, &format!("worktree for issue #{issue}"))?;
2072        Ok((path, branch))
2073    }
2074
2075    /// Refuse to reset the local or remote branch assigned to an issue when it
2076    /// carries work that no pull request preserves.
2077    ///
2078    /// Both linked-worktree mode and shared-checkout mode rebuild the same
2079    /// branch name. Keeping the guard here prevents either path from silently
2080    /// replacing recovery commits left by an earlier run.
2081    pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
2082        let branch = self.branch_for_issue(issue);
2083        let base_remote_ref = format!("refs/heads/{base}");
2084        let base_tracking_ref = format!("refs/remotes/origin/{base}");
2085        let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
2086        self.git(&["fetch", "--no-tags", "origin", &base_refspec])
2087            .map_err(|e| {
2088                spar_err!(
2089                    "could not refresh origin/{base} before checking issue #{issue}: {}",
2090                    e.last_line()
2091                )
2092            })?;
2093
2094        if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
2095            let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
2096            if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
2097                bail!(
2098                    "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
2099                     pull request accounts for them. Rebuilding it would force push over that \
2100                     work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
2101                     it, or delete it with `git push origin --delete {branch}` if the remote \
2102                     branch is no longer needed."
2103                );
2104            }
2105        }
2106
2107        // The same question of the fork a refused push would go to, asked here
2108        // rather than after an agent has spent its call implementing.
2109        if self.fork_when_denied && self.origin_refuses_push() {
2110            match self.existing_fork() {
2111                Ok(Some(fork)) => {
2112                    self.fork_branch_lease(&fork, &branch, base)?;
2113                }
2114                Ok(None) => {}
2115                Err(e) => logdim!("{e} It is checked again before anything is pushed to it."),
2116            }
2117        }
2118
2119        let local_ref = format!("refs/heads/{branch}");
2120        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2121            let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
2122            let recorded_pr = self
2123                .known_branches()
2124                .get(&branch)
2125                .is_some_and(|record| record.kind == "pr");
2126            let preserved = ahead == 0
2127                || if recorded_pr {
2128                    self.local_branch_is_preserved(&branch)?
2129                } else {
2130                    self.pull_request_holds(&branch, &local_ref, base)
2131                };
2132            if !preserved {
2133                let listed = self
2134                    .commit_lines(&self.root, &local_ref, base)
2135                    .iter()
2136                    .map(|line| format!("  {line}"))
2137                    .collect::<Vec<_>>()
2138                    .join("\n");
2139                bail!(
2140                    "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
2141                     no pull request preserves them. Rebuilding it would delete the only copy.\n\
2142                     {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
2143                     it, or delete it with `git branch -D {branch}` if it is stale."
2144                );
2145            }
2146        }
2147        Ok(())
2148    }
2149
2150    fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
2151        let live_ref = format!("refs/heads/{branch}");
2152        let tracking_ref = format!("refs/remotes/origin/{branch}");
2153        let listed = self
2154            .git(&["ls-remote", "--heads", "origin", &live_ref])
2155            .map_err(|e| {
2156                spar_err!(
2157                    "could not verify whether origin/{branch} still exists: {}",
2158                    e.last_line()
2159                )
2160            })?;
2161
2162        if remote_head_oid(&listed, &live_ref)?.is_some() {
2163            let refspec = format!("+{live_ref}:{tracking_ref}");
2164            self.git(&["fetch", "--no-tags", "origin", &refspec])
2165                .map_err(|e| {
2166                    spar_err!(
2167                        "origin/{branch} exists but its tracking ref could not be refreshed: {}",
2168                        e.last_line()
2169                    )
2170                })?;
2171            if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2172                bail!("origin/{branch} was fetched but its tracking ref is missing");
2173            }
2174            return Ok(Some(tracking_ref));
2175        }
2176
2177        if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2178            return Ok(None);
2179        }
2180        let expected = self
2181            .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
2182            .trim()
2183            .to_string();
2184        self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
2185            .map_err(|e| {
2186                spar_err!(
2187                    "could not discard stale origin/{branch} tracking ref safely: {}",
2188                    e.last_line()
2189                )
2190            })?;
2191        if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2192            bail!(
2193                "origin/{branch} changed while its stale tracking ref was being removed. It was \
2194                 kept."
2195            );
2196        }
2197        Ok(None)
2198    }
2199
2200    /// Whether a pull request from `branch` already holds every commit `refname`
2201    /// has beyond `base`.
2202    ///
2203    /// GitHub serves `refs/pull/N/head` for as long as the repository lives, so
2204    /// commits that reached a pull request outlive the branch they were pushed
2205    /// from. A matching branch name does not establish that on its own: an
2206    /// issue worked twice reuses the name, and the merged pull request from the
2207    /// first round says nothing about where the second round's commits are.
2208    fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
2209        self.prs_for_branch(branch)
2210            .iter()
2211            .any(|pr| self.pr_head_holds(pr.number, refname, base))
2212    }
2213
2214    fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
2215        let head = format!("refs/spar/pr-head/{number}");
2216        let refspec = format!("+refs/pull/{number}/head:{head}");
2217        if self.git(&["fetch", "origin", &refspec]).is_err() {
2218            return false;
2219        }
2220        let held = self.commits_held_by(refname, base, &head);
2221        self.git_try(&["update-ref", "-d", &head]);
2222        held
2223    }
2224
2225    pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
2226        let argv = vec![
2227            "git".to_string(),
2228            "merge-base".to_string(),
2229            "--is-ancestor".to_string(),
2230            older.to_string(),
2231            newer.to_string(),
2232        ];
2233        let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
2234        match out.code {
2235            0 => Ok(true),
2236            1 => Ok(false),
2237            _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
2238        }
2239    }
2240
2241    fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
2242        let head = format!("refs/spar/pr-head/{number}");
2243        let refspec = format!("+refs/pull/{number}/head:{head}");
2244        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2245            spar_err!(
2246                "could not verify the immutable head of PR #{number}: {}",
2247                e.last_line()
2248            )
2249        })?;
2250        let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
2251        self.git_try(&["update-ref", "-d", &head]);
2252        held
2253    }
2254
2255    fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
2256        let text = self.gh(&[
2257            "pr",
2258            "list",
2259            "--head",
2260            branch,
2261            "--state",
2262            "all",
2263            "--json",
2264            "number,url,title",
2265        ])?;
2266        serde_json::from_str(text.trim())
2267            .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
2268    }
2269
2270    fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
2271        let branch_ref = format!("refs/heads/{branch}");
2272        if record.kind == "pr" {
2273            return self.pr_head_contains_checked(record.number, &branch_ref);
2274        }
2275        let prs = self.branch_prs_checked(branch)?;
2276        if prs.is_empty() {
2277            return Ok(false);
2278        }
2279        for pr in prs {
2280            if self.pr_head_contains_checked(pr.number, &branch_ref)? {
2281                return Ok(true);
2282            }
2283        }
2284        Ok(false)
2285    }
2286
2287    fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
2288        let local_ref = format!("refs/heads/{branch}");
2289        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2290            return Ok(true);
2291        }
2292        let oid = self
2293            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2294            .trim()
2295            .to_string();
2296        let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
2297        if !durable_tip {
2298            let remote_ref = format!("refs/heads/{branch}");
2299            let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
2300            durable_tip = remote.lines().any(|line| {
2301                line.split_whitespace()
2302                    .next()
2303                    .is_some_and(|remote_oid| remote_oid == oid)
2304            });
2305        }
2306        if !durable_tip {
2307            if let Some(record) = self.known_branches().get(branch) {
2308                durable_tip = self.branch_is_preserved_checked(branch, record)?;
2309            }
2310        }
2311        if !durable_tip {
2312            return Ok(false);
2313        }
2314        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2315    }
2316
2317    /// Delete a branch only if its exact current tip and reflog are still safe.
2318    /// The expected old value makes a concurrent ref update fail instead of
2319    /// deleting work that appeared after the preservation check.
2320    fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
2321        let local_ref = format!("refs/heads/{branch}");
2322        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2323            return Ok(true);
2324        }
2325        let expected = self
2326            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2327            .trim()
2328            .to_string();
2329        if !self.branch_deletion_is_safe(branch)? {
2330            return Ok(false);
2331        }
2332        let checked_out = self
2333            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
2334            .lines()
2335            .any(|line| line == format!("branch {local_ref}"));
2336        if checked_out {
2337            return Ok(false);
2338        }
2339        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2340        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2341    }
2342
2343    fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
2344        let local_ref = review_ref(number);
2345        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2346            return Ok(true);
2347        }
2348        let oid = self
2349            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2350            .trim()
2351            .to_string();
2352        if !self.pr_head_contains_checked(number, &local_ref)? {
2353            return Ok(false);
2354        }
2355        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2356    }
2357
2358    fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
2359        let local_ref = review_ref(number);
2360        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2361            return Ok(true);
2362        }
2363        let expected = self
2364            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2365            .trim()
2366            .to_string();
2367        if !self.review_ref_deletion_is_safe(number)? {
2368            return Ok(false);
2369        }
2370        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2371        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2372    }
2373
2374    /// Whether `other` already contains every commit `branch` has beyond
2375    /// `base`. False when either ref fails to resolve, so a ref that is not
2376    /// there cannot vouch for anything.
2377    pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
2378        let range = format!("{}..{branch}", self.base_ref(&self.root, base));
2379        self.git_try(&["rev-list", "--count", &range, "--not", other])
2380            .trim()
2381            == "0"
2382    }
2383
2384    pub fn worktree_remove(&self, issue: i64) -> bool {
2385        let path = self.worktree_path(&format!("issue-{issue}"));
2386        match self.remove_worktree_at(&path) {
2387            Ok(removed) => removed,
2388            Err(error) => {
2389                logdim!(
2390                    "kept {} because removal did not reach a confirmed quiet point: {}",
2391                    path.display(),
2392                    error.last_line()
2393                );
2394                false
2395            }
2396        }
2397    }
2398
2399    /// Verify both the common Git directory and the worktree top level.
2400    ///
2401    /// A stale worktree entry is not ownership proof. An unrelated repository
2402    /// can later occupy the same path and must survive cleanup.
2403    fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
2404        let wanted = std::fs::canonicalize(path)
2405            .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
2406        // Every SPAR worktree path is built from the canonical repository root.
2407        // A different canonical path therefore means the final component or
2408        // one of its parents is a symlink. Passing that alias to `git worktree
2409        // remove` can delete the worktree at its real target.
2410        if wanted != path {
2411            return Ok(false);
2412        }
2413        let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
2414            let raw = PathBuf::from(value.trim());
2415            let joined = if raw.is_absolute() {
2416                raw
2417            } else {
2418                cwd.join(raw)
2419            };
2420            std::fs::canonicalize(&joined)
2421                .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
2422        };
2423        let expected =
2424            self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
2425        let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
2426        let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
2427        let expected = resolve(&self.root, &expected)?;
2428        let actual = resolve(path, &actual)?;
2429        let top = resolve(path, &top)?;
2430        Ok(expected == actual && top == wanted)
2431    }
2432
2433    /// Remove only a worktree that belongs to this repository.
2434    ///
2435    /// The path sits under a predictable directory, but that does not establish
2436    /// ownership. A clean independent repository at the same path must survive
2437    /// even when `git worktree remove` rejects it.
2438    fn remove_worktree_at_with_force(
2439        &self,
2440        path: &Path,
2441        force: bool,
2442        disposable: Disposable,
2443    ) -> Result<bool> {
2444        let existed = path.exists();
2445        if path.exists() {
2446            match self.worktree_belongs_to_repo(path) {
2447                Ok(true) => {}
2448                Ok(false) => {
2449                    logdim!(
2450                        "kept {} because it is not a worktree owned by this repository",
2451                        path.display()
2452                    );
2453                    return Ok(false);
2454                }
2455                Err(e) => {
2456                    logdim!(
2457                        "kept {} because its worktree ownership could not be verified: {}",
2458                        path.display(),
2459                        e.last_line()
2460                    );
2461                    return Ok(false);
2462                }
2463            }
2464            if !force {
2465                match self.has_recoverable_work(path, disposable) {
2466                    Ok(true) => {
2467                        logdim!(
2468                            "kept {} because it contains recoverable files or repository state",
2469                            path.display()
2470                        );
2471                        return Ok(false);
2472                    }
2473                    Err(e) => {
2474                        logdim!(
2475                            "kept {} because its recoverable state could not be checked: {}",
2476                            path.display(),
2477                            e.last_line()
2478                        );
2479                        return Ok(false);
2480                    }
2481                    Ok(false) => {}
2482                }
2483            }
2484        }
2485        // Git refuses to remove a worktree holding any untracked file, which is
2486        // the same question SPAR has just answered in more detail: under
2487        // `ReviewOutput` the checks above have already established that nothing
2488        // there is worth keeping. `--force` here is that answer being carried
2489        // out, not the check being skipped, which is what `force` means.
2490        let path_str = path.display().to_string();
2491        let command_ok = if force || disposable == Disposable::ReviewOutput {
2492            self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
2493        } else {
2494            self.git_try_without_automation(&["worktree", "remove", &path_str])?
2495        };
2496        Ok((command_ok || !existed) && !path.exists())
2497    }
2498
2499    fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
2500        self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
2501    }
2502
2503    /// Remove a review checkout that is about to be rebuilt from origin.
2504    fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
2505        self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
2506    }
2507
2508    /// Force removal is reserved for the explicit `clean --all` path.
2509    fn remove_worktree_at_force(&self, path: &Path) -> bool {
2510        match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
2511            Ok(removed) => removed,
2512            Err(error) => {
2513                logdim!(
2514                    "kept {} because removal did not reach a confirmed quiet point: {}",
2515                    path.display(),
2516                    error.last_line()
2517                );
2518                false
2519            }
2520        }
2521    }
2522
2523    /// Remove a worktree only after a caller has verified it is unchanged.
2524    ///
2525    /// There is deliberately no force fallback, so Git can still refuse a
2526    /// removal if tracked or non-ignored work appears after the final check.
2527    fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
2528        if path.exists() && !self.worktree_belongs_to_repo(path)? {
2529            bail!(
2530                "{} is not a worktree owned by this repository, so it was kept",
2531                path.display()
2532            );
2533        }
2534        if path.exists() && self.has_recoverable_work(path, disposable)? {
2535            bail!(
2536                "the verified worktree at {} contains recoverable files or repository state. It \
2537                 was kept.",
2538                path.display()
2539            );
2540        }
2541        let path_str = path.display().to_string();
2542        self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
2543            .map_err(|e| {
2544                e.with_message(format!(
2545                    "could not remove the verified worktree at {}: {}. It was kept.",
2546                    path.display(),
2547                    e.last_line()
2548                ))
2549            })?;
2550        Ok(!path.exists())
2551    }
2552
2553    fn refuse_dirty_worktree(
2554        &self,
2555        path: &Path,
2556        label: &str,
2557        disposable: Disposable,
2558    ) -> Result<()> {
2559        if !path.is_dir() {
2560            return Ok(());
2561        }
2562        let has_files = std::fs::read_dir(path)
2563            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2564            .next()
2565            .is_some();
2566        let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
2567            spar_err!(
2568                "could not verify whether the existing {label} at {} belongs to this repository, \
2569                 so it was kept: {}",
2570                path.display(),
2571                e.last_line()
2572            )
2573        })?;
2574        if !owned {
2575            if has_files {
2576                bail!(
2577                    "the existing {label} at {} is not a worktree owned by \
2578                     this repository. Refusing to remove it.",
2579                    path.display()
2580                );
2581            }
2582            return Ok(());
2583        }
2584        if !path.join(".git").exists() {
2585            if has_files {
2586                bail!(
2587                    "the existing {label} at {} is not a readable Git worktree and is not empty. \
2588                     Refusing to remove it.",
2589                    path.display()
2590                );
2591            }
2592            return Ok(());
2593        }
2594        let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2595            spar_err!(
2596                "could not verify whether the existing {label} at {} is clean, so it was kept: \
2597                 {}",
2598                path.display(),
2599                e.last_line()
2600            )
2601        })?;
2602        if dirty {
2603            bail!(
2604                "the existing {label} contains uncommitted changes or ignored files at {}. \
2605                 Rebuilding it would delete those files.\nCommit or recover them before running this \
2606                 command again, or use `spar clean --all` if they are not needed.",
2607                path.display()
2608            );
2609        }
2610        Ok(())
2611    }
2612
2613    /// Check an existing PR branch out into an isolated worktree, from
2614    /// wherever `target` says the branch is published.
2615    pub fn worktree_for_pr(&self, pr: &PrView, target: &PushTarget) -> Result<(PathBuf, String)> {
2616        let head = pr.head_ref_name.clone();
2617        if head.trim().is_empty() {
2618            bail!("PR #{} has no head branch to check out", pr.number);
2619        }
2620        let path = self.worktree_path(&format!("pr-{}", pr.number));
2621        let local = self.branch_for_pr(pr.number);
2622
2623        let unfetched = |e: crate::error::SparError| {
2624            spar_err!(
2625                "could not fetch the branch behind PR #{}: {}",
2626                pr.number,
2627                e.last_line()
2628            )
2629        };
2630        // What the worktree starts from, the ref behind it, and what to call
2631        // it when saying so.
2632        let (start, start_ref, shown) = match target {
2633            PushTarget::Origin => {
2634                self.git(&["fetch", "origin", &head]).map_err(unfetched)?;
2635                let start = format!("origin/{head}");
2636                (start.clone(), format!("refs/remotes/origin/{head}"), start)
2637            }
2638            // A fork has no remote here to track it, so its branch is
2639            // fetched into a ref of spar's own.
2640            PushTarget::Fork(fork) => {
2641                let start_ref = format!("refs/spar/fork/{head}");
2642                let refspec = format!("+refs/heads/{head}:{start_ref}");
2643                self.git(&["fetch", "--no-tags", &fork.url, &refspec])
2644                    .map_err(unfetched)?;
2645                (start_ref.clone(), start_ref, target.describe(&head))
2646            }
2647        };
2648        let local_ref = format!("refs/heads/{local}");
2649        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2650            let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2651            if unpushed > 0 {
2652                bail!(
2653                    "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2654                     on {shown}. Rebuilding it would delete their branch.\nInspect the worktree at \
2655                     {} and push or recover those commits before running this command again.",
2656                    pr.number,
2657                    path.display()
2658                );
2659            }
2660        }
2661        self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2662        self.refuse_dirty_worktree(
2663            &path,
2664            &format!("worktree for PR #{}", pr.number),
2665            Disposable::BuildOutput,
2666        )?;
2667        if !self.branch_deletion_is_safe(&local)? {
2668            bail!(
2669                "the existing branch {local} has a tip or reflog-only commit that no surviving \
2670                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2671                 before retrying."
2672            );
2673        }
2674        if !self.remove_worktree_at(&path)? {
2675            bail!(
2676                "the existing worktree for PR #{} could not be removed safely. Its branch was \
2677                 kept.",
2678                pr.number
2679            );
2680        }
2681        if !self.delete_branch_if_safe(&local)? {
2682            bail!(
2683                "the existing branch {local} changed or remained checked out while the PR \
2684                 worktree was being rebuilt. It was kept."
2685            );
2686        }
2687
2688        let path_str = path.display().to_string();
2689        self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2690        self.record_branch(&local, "pr", pr.number);
2691        self.set_up_worktree(&path, &format!("worktree for PR #{}", pr.number))?;
2692        Ok((path, head))
2693    }
2694
2695    /// A retry needs a fresh checkout, not permission to delete ignored data.
2696    /// Keep that data and its branch when all commits are already published.
2697    fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2698        if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2699            return Ok(());
2700        }
2701        let current =
2702            self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2703        if current.trim() != branch
2704            || self.has_uncommitted_changes(path)?
2705            || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2706        {
2707            return Ok(());
2708        }
2709        static NEXT: AtomicUsize = AtomicUsize::new(0);
2710        let (name, retained) = loop {
2711            let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2712            let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2713            let retained = self.worktree_path(&name);
2714            if !retained
2715                .try_exists()
2716                .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2717                && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2718            {
2719                break (name, retained);
2720            }
2721        };
2722        // Neither command overwrites an existing destination. The branch rename
2723        // preserves its reflog, and the worktree move preserves every file.
2724        self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2725        if let Err(error) = self.git_at_without_automation(
2726            &self.root,
2727            &[
2728                "worktree",
2729                "move",
2730                &path.display().to_string(),
2731                &retained.display().to_string(),
2732            ],
2733        ) {
2734            // A locked worktree or one with submodules cannot always be moved.
2735            // Restore the branch name so a failed move does not strand a retry.
2736            let rollback =
2737                self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2738            let detail = rollback
2739                .err()
2740                .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2741                .unwrap_or_default();
2742            bail!(
2743                "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2744                retained.display(),
2745                error.last_line()
2746            );
2747        }
2748        logdim!(
2749            "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2750            retained.display()
2751        );
2752        Ok(())
2753    }
2754
2755    /// Check a pull request's head out read only, detached, with no branch.
2756    ///
2757    /// Fetches `refs/pull/N/head`, which GitHub serves for every pull request
2758    /// including one from a fork whose branch is not in this repository at all.
2759    /// That is what makes reviewing an outside contribution possible when
2760    /// pushing to it is not.
2761    ///
2762    /// Detached on purpose. Review only mode has nothing to push, and a branch
2763    /// would only invite something to try.
2764    pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2765        let path = self.worktree_path(&format!("review-{number}"));
2766        let local_ref = review_ref(number);
2767        let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2768
2769        self.refuse_review_worktree_changes(number)?;
2770
2771        if let Some(parent) = path.parent() {
2772            std::fs::create_dir_all(parent)
2773                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2774        }
2775        // Removed before the fetch, while `local_ref` still holds the head
2776        // this checkout is at. A force push moves the ref off that commit,
2777        // and the checkout's reflog would then name a commit no ref holds,
2778        // which the removal check rightly keeps as local work.
2779        if !self.remove_review_worktree_at(&path)? {
2780            bail!(
2781                "the existing review worktree for PR #{number} could not be removed safely. Its \
2782                 reference was kept."
2783            );
2784        }
2785
2786        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2787            spar_err!(
2788                "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2789                 every pull request, so this usually means the number is wrong or `origin` does \
2790                 not point at the repository the PR is on.",
2791                e.last_line()
2792            )
2793        })?;
2794
2795        let path_str = path.display().to_string();
2796        self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2797        self.set_up_worktree(&path, &format!("review worktree for PR #{number}"))?;
2798        Ok(path)
2799    }
2800
2801    fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2802        let path = self.worktree_path(&format!("review-{number}"));
2803        if !path.is_dir() {
2804            return Ok(());
2805        }
2806        let local_ref = review_ref(number);
2807        if !self.worktree_belongs_to_repo(&path)? {
2808            return Ok(());
2809        }
2810        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2811            bail!(
2812                "the existing review worktree for PR #{number} has no recorded head at \
2813                 {local_ref}. Refusing to rebuild {}.",
2814                path.display()
2815            );
2816        }
2817        let worktree_head = self.head_oid_checked(&path)?;
2818        let recorded_head = self
2819            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2820            .trim()
2821            .to_string();
2822        if worktree_head != recorded_head {
2823            bail!(
2824                "the existing review worktree for PR #{number} has a local commit that is not on \
2825                 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2826                 {} before retrying.",
2827                path.display()
2828            );
2829        }
2830        self.report_recovery_markers(&path);
2831        self.refuse_dirty_worktree(
2832            &path,
2833            &format!("review worktree for PR #{number}"),
2834            Disposable::ReviewOutput,
2835        )?;
2836        Ok(())
2837    }
2838
2839    /// Read out what an earlier run refused to explain away, before rebuilding
2840    /// over it.
2841    ///
2842    /// The marker is the only record of why a call's answer was thrown out, and
2843    /// a review checkout is rebuilt on the next run. Saying it here is what
2844    /// keeps the file from being the thing that quietly blocks the command
2845    /// instead.
2846    fn report_recovery_markers(&self, path: &Path) {
2847        let Ok(entries) = std::fs::read_dir(path) else {
2848            return;
2849        };
2850        for entry in entries.flatten() {
2851            let name = entry.file_name();
2852            let Some(name) = name.to_str() else { continue };
2853            if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2854                continue;
2855            }
2856            match std::fs::read_to_string(entry.path()) {
2857                Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2858                Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2859            }
2860        }
2861    }
2862
2863    /// A checkout of HEAD for calls that only read, made fresh for them.
2864    ///
2865    /// With `[loop] worktrees` off the calls stay in the shared checkout,
2866    /// which is what that setting asks of every call, and so they do when
2867    /// nothing is committed yet, since there is no commit to check out.
2868    pub fn read_checkout(&self, label: &str) -> Result<ReadCheckout<'_>> {
2869        let shared = || ReadCheckout {
2870            repo: self,
2871            path: self.root.clone(),
2872            checkpoint: None,
2873        };
2874        if !self.isolate_reads {
2875            return Ok(shared());
2876        }
2877        let Some(head) = resolve_optional_commit(&self.root, "HEAD")? else {
2878            return Ok(shared());
2879        };
2880        static NEXT: AtomicUsize = AtomicUsize::new(0);
2881        let path = loop {
2882            let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2883            let path = self.worktree_path(&format!(
2884                "{READ_CHECKOUT_PREFIX}{}-{serial}",
2885                std::process::id()
2886            ));
2887            if !path
2888                .try_exists()
2889                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2890            {
2891                break path;
2892            }
2893        };
2894        if let Some(parent) = path.parent() {
2895            std::fs::create_dir_all(parent)
2896                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2897        }
2898        let path_str = path.display().to_string();
2899        self.git(&["worktree", "add", "--detach", &path_str, &head])
2900            .map_err(|e| {
2901                spar_err!(
2902                    "could not check HEAD out for the {label}: {}",
2903                    e.last_line()
2904                )
2905            })?;
2906        let ready = self
2907            .set_up_worktree(&path, &format!("read-only checkout for the {label}"))
2908            .and_then(|()| self.worktree_checkpoint(&path));
2909        match ready {
2910            Ok(checkpoint) => Ok(ReadCheckout {
2911                repo: self,
2912                path,
2913                checkpoint: Some(checkpoint),
2914            }),
2915            Err(e) => {
2916                // No call has run in it yet, so nothing in it is anybody's.
2917                self.discard_read_checkout(&path);
2918                Err(e)
2919            }
2920        }
2921    }
2922
2923    /// Remove a read checkout once nothing in it has changed since setup.
2924    ///
2925    /// What can be left after that is ignored output from calls that were
2926    /// allowed to leave it, in a checkout nobody else works in, so it goes
2927    /// with the checkout. A recovery marker means a call's answer was already
2928    /// refused over what it did there, and that error said where to look.
2929    fn release_read_checkout(&self, path: &Path, checkpoint: &WorktreeCheckpoint) {
2930        if self.keep_worktrees {
2931            logdim!("kept the read-only checkout at {}", path.display());
2932            return;
2933        }
2934        if has_recovery_marker(path) {
2935            logdim!(
2936                "kept the read-only checkout at {} for recovery",
2937                path.display()
2938            );
2939            return;
2940        }
2941        if let Err(e) = self.require_unchanged_worktree(path, checkpoint, "read-only checkout") {
2942            logwarn!("{e}");
2943            return;
2944        }
2945        self.discard_read_checkout(path);
2946    }
2947
2948    fn discard_read_checkout(&self, path: &Path) {
2949        match self.remove_worktree_at_checked(path, Disposable::ReviewOutput) {
2950            Ok(true) => {}
2951            Ok(false) => logdim!(
2952                "kept the read-only checkout at {} because it was still there after removal",
2953                path.display()
2954            ),
2955            Err(e) => logdim!(
2956                "kept the read-only checkout at {}: {}",
2957                path.display(),
2958                e.last_line()
2959            ),
2960        }
2961    }
2962
2963    /// A worktree for one part of a split, on a new branch off `start`.
2964    ///
2965    /// `start` is the base branch for independent parts and the previous part's
2966    /// branch for stacked ones, which is the only difference between the two
2967    /// shapes at this level.
2968    ///
2969    /// The branch is whatever name was free, which is why it is returned rather
2970    /// than derived by the caller. Splitting the same pull request a second
2971    /// time would otherwise target the branch behind the first run's pull
2972    /// request. Split pushes are create-only and would refuse that target, but
2973    /// a repeated split still needs distinct branches rather than a name that
2974    /// can never be created.
2975    pub fn worktree_for_split(
2976        &self,
2977        parent: i64,
2978        index: usize,
2979        start: &str,
2980    ) -> Result<(PathBuf, String)> {
2981        let slot = self.free_split_slot(parent, index)?;
2982        let branch = format!("{}{slot}", self.branch_prefix);
2983        let path = self.worktree_path(&slot);
2984
2985        if let Some(dir) = path.parent() {
2986            std::fs::create_dir_all(dir)
2987                .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2988        }
2989        self.refuse_dirty_worktree(
2990            &path,
2991            &format!("worktree for part {index} of PR #{parent}"),
2992            Disposable::BuildOutput,
2993        )?;
2994        // The name is free, so there is no branch to delete. A directory can
2995        // still be in the way, left by a worktree that was pruned from git's
2996        // records without being removed from disk.
2997        if !self.remove_worktree_at(&path)? {
2998            bail!(
2999                "the existing worktree for part {index} of PR #{parent} could not be removed \
3000                 safely. No branch was created."
3001            );
3002        }
3003
3004        let path_str = path.display().to_string();
3005        self.git(&["worktree", "add", "-b", &branch, &path_str, start])
3006            .map_err(|e| {
3007                spar_err!(
3008                    "could not create a worktree for part {index} of #{parent}. {}",
3009                    e.last_line()
3010                )
3011            })?;
3012        // Recorded before anything else can fail. An unrecorded branch is one
3013        // `prune_branches` will never remove.
3014        self.record_branch(&branch, "split", parent);
3015        self.set_up_worktree(&path, &format!("worktree for part {index} of PR #{parent}"))?;
3016        Ok((path, branch))
3017    }
3018
3019    /// The first part branch nothing is already sitting on.
3020    ///
3021    /// Origin as well as local, because a part's branch outlives the local one:
3022    /// a second split of the same pull request finds its own earlier branches
3023    /// deleted here but alive on origin, where the pull requests that reviewed
3024    /// them still point at them.
3025    fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
3026        for attempt in 1..=SPLIT_SLOTS {
3027            let slot = split_slot(parent, index, attempt);
3028            let branch = format!("{}{slot}", self.branch_prefix);
3029            self.git_try(&["fetch", "origin", &branch]);
3030            if !self.rev_exists(&self.root, &branch)
3031                && !self.rev_exists(&self.root, &format!("origin/{branch}"))
3032            {
3033                return Ok(slot);
3034            }
3035        }
3036        bail!(
3037            "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
3038             names are all taken. Inspect the existing branches and child pull requests. Finish \
3039             recording the earlier split, or remove every retained local worktree and branch, \
3040             child pull request, and remote split branch before starting over.",
3041            self.branch_for_split(parent, index)
3042        )
3043    }
3044
3045    /// Whether a previous attempt pushed any branch for this split.
3046    ///
3047    /// The parent comment is the normal retry marker. A branch is the fallback
3048    /// when that comment or the pull request creation failed after the push.
3049    /// Reading origin directly makes the guard survive a fresh clone.
3050    pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
3051        let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
3052        Ok(!self
3053            .git(&["ls-remote", "--heads", "origin", &pattern])?
3054            .trim()
3055            .is_empty())
3056    }
3057
3058    /// Throw one part away: its worktree, its branch, and its record.
3059    ///
3060    /// For a part that would not stand on its own. Nothing has been pushed at
3061    /// that point, so this leaves no trace anywhere but the log. Takes what
3062    /// `worktree_for_split` returned, since the name it settled on is not
3063    /// derivable from the parent and the index.
3064    pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
3065        match self.branch_deletion_is_safe(branch) {
3066            Ok(true) => {}
3067            Ok(false) => {
3068                logdim!(
3069                    "kept {branch} and {} because no surviving ref preserves its tip",
3070                    dir.display()
3071                );
3072                return;
3073            }
3074            Err(error) => {
3075                logdim!(
3076                    "kept {branch} and {} because preservation could not be verified: {}",
3077                    dir.display(),
3078                    error.last_line()
3079                );
3080                return;
3081            }
3082        }
3083        match self.remove_worktree_at(dir) {
3084            Ok(true) => match self.delete_branch_if_safe(branch) {
3085                Ok(true) => self.forget_branch(branch),
3086                Ok(false) => {
3087                    logdim!("kept {branch} because its tip or reflog changed before deletion")
3088                }
3089                Err(error) => logdim!(
3090                    "kept {branch} because deletion safety could not be rechecked: {}",
3091                    error.last_line()
3092                ),
3093            },
3094            Ok(false) => {}
3095            Err(error) => logdim!(
3096                "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
3097                dir.display(),
3098                error.last_line()
3099            ),
3100        }
3101    }
3102
3103    /// Discard one exact mechanical slice that the split workflow just made.
3104    ///
3105    /// Unlike ordinary release, this intentionally removes an unpushed commit.
3106    /// The caller supplies the exact disposable tip, and every file, worktree,
3107    /// ownership, and ref check must still match before anything is removed.
3108    pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
3109        let record = self.known_branches().get(branch).cloned();
3110        if record.is_none_or(|record| record.kind != "split") {
3111            logdim!("kept {branch} because no split branch record proves ownership");
3112            return false;
3113        }
3114        let local_ref = format!("refs/heads/{branch}");
3115        let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
3116            Ok(value) => value.trim().to_string(),
3117            Err(error) => {
3118                logdim!(
3119                    "kept {branch} because its tip could not be checked: {}",
3120                    error.last_line()
3121                );
3122                return false;
3123            }
3124        };
3125        if expected != disposable_head {
3126            logdim!("kept {branch} because it moved beyond the disposable slice");
3127            return false;
3128        }
3129        match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
3130            Ok(true) => {}
3131            Ok(false) => {
3132                logdim!(
3133                    "kept {branch} because its reflog contains work outside the disposable slice"
3134                );
3135                return false;
3136            }
3137            Err(error) => {
3138                logdim!(
3139                    "kept {branch} because its reflog could not be checked: {}",
3140                    error.last_line()
3141                );
3142                return false;
3143            }
3144        }
3145        match self.head_oid_checked(dir) {
3146            Ok(head) if head == disposable_head => {}
3147            Ok(_) => {
3148                logdim!(
3149                    "kept {branch} and {} because the worktree moved beyond the disposable slice",
3150                    dir.display()
3151                );
3152                return false;
3153            }
3154            Err(error) => {
3155                logdim!(
3156                    "kept {branch} and {} because its head could not be checked: {}",
3157                    dir.display(),
3158                    error.last_line()
3159                );
3160                return false;
3161            }
3162        }
3163        match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
3164            Ok(true) => {}
3165            Ok(false) => return false,
3166            Err(error) => {
3167                logdim!(
3168                    "kept {branch} and {} because the disposable slice could not be verified: {}",
3169                    dir.display(),
3170                    error.last_line()
3171                );
3172                return false;
3173            }
3174        }
3175        if let Err(error) =
3176            self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
3177        {
3178            logdim!(
3179                "kept {branch} because its exact disposable tip could not be deleted: {}",
3180                error.last_line()
3181            );
3182            return false;
3183        }
3184        match self.exact_ref_exists_checked(&self.root, &local_ref) {
3185            Ok(false) => {
3186                self.forget_branch(branch);
3187                true
3188            }
3189            Ok(true) => {
3190                logdim!("kept {branch} because its ref still exists after deletion");
3191                false
3192            }
3193            Err(error) => {
3194                logdim!(
3195                    "kept the branch record for {branch} because deletion could not be verified: {}",
3196                    error.last_line()
3197                );
3198                false
3199            }
3200        }
3201    }
3202
3203    pub fn release_review_worktree(&self, number: i64) {
3204        let path = self.worktree_path(&format!("review-{number}"));
3205        match self.review_ref_deletion_is_safe(number) {
3206            Ok(true) => {}
3207            Ok(false) => {
3208                logdim!(
3209                    "kept {} because no surviving ref preserves its review history",
3210                    path.display()
3211                );
3212                return;
3213            }
3214            Err(error) => {
3215                logdim!(
3216                    "kept {} because review history could not be verified: {}",
3217                    path.display(),
3218                    error.last_line()
3219                );
3220                return;
3221            }
3222        }
3223        match self.remove_worktree_at(&path) {
3224            Ok(true) => match self.delete_review_ref_if_safe(number) {
3225                Ok(true) => {}
3226                Ok(false) => logdim!(
3227                    "kept {} because its review history changed before deletion",
3228                    review_ref(number)
3229                ),
3230                Err(error) => logdim!(
3231                    "kept {} because deletion safety could not be rechecked: {}",
3232                    review_ref(number),
3233                    error.last_line()
3234                ),
3235            },
3236            Ok(false) => {}
3237            Err(error) => logdim!(
3238                "kept {} because removal did not reach a confirmed quiet point: {}",
3239                path.display(),
3240                error.last_line()
3241            ),
3242        }
3243    }
3244
3245    /// Release a read-only review checkout only when every observed part of
3246    /// its Git state still matches the checkpoint captured before the calls.
3247    pub(crate) fn release_review_worktree_checked(
3248        &self,
3249        number: i64,
3250        checkpoint: &WorktreeCheckpoint,
3251    ) -> Result<()> {
3252        let path = self.worktree_path(&format!("review-{number}"));
3253        self.require_unchanged_worktree(
3254            &path,
3255            checkpoint,
3256            &format!("review worktree for PR #{number}"),
3257        )?;
3258        if !self.review_ref_deletion_is_safe(number)? {
3259            bail!(
3260                "the review reference for PR #{number} has reflog-only recovery history. The \
3261                 worktree and reference were kept."
3262            );
3263        }
3264        if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
3265            bail!(
3266                "the verified review worktree at {} could not be removed, so its reference was \
3267                 kept",
3268                path.display()
3269            );
3270        }
3271        if !self.delete_review_ref_if_safe(number)? {
3272            bail!(
3273                "the review reference for PR #{number} changed before deletion. The reference was \
3274                 kept."
3275            );
3276        }
3277        Ok(())
3278    }
3279
3280    pub fn release_pr_worktree(&self, number: i64) -> bool {
3281        let path = self.worktree_path(&format!("pr-{number}"));
3282        let local = self.branch_for_pr(number);
3283        match self.branch_deletion_is_safe(&local) {
3284            Ok(true) => {}
3285            Ok(false) => {
3286                logdim!(
3287                    "kept {local} and {} because no surviving ref preserves its tip",
3288                    path.display()
3289                );
3290                return false;
3291            }
3292            Err(error) => {
3293                logdim!(
3294                    "kept {local} and {} because preservation could not be verified: {}",
3295                    path.display(),
3296                    error.last_line()
3297                );
3298                return false;
3299            }
3300        }
3301        match self.remove_worktree_at(&path) {
3302            Ok(true) => match self.delete_branch_if_safe(&local) {
3303                Ok(true) => {
3304                    self.forget_branch(&local);
3305                    true
3306                }
3307                Ok(false) => {
3308                    logdim!("kept {local} because its tip or reflog changed before deletion");
3309                    false
3310                }
3311                Err(error) => {
3312                    logdim!(
3313                        "kept {local} because deletion safety could not be rechecked: {}",
3314                        error.last_line()
3315                    );
3316                    false
3317                }
3318            },
3319            Ok(false) => false,
3320            Err(error) => {
3321                logdim!(
3322                    "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
3323                    path.display(),
3324                    error.last_line()
3325                );
3326                false
3327            }
3328        }
3329    }
3330
3331    // -- branch state -----------------------------------------------------
3332
3333    /// What to diff against: the remote tracking branch when it resolves, the
3334    /// local branch when it does not.
3335    ///
3336    /// This is not a nicety. Every "did the agent do anything" check hangs off
3337    /// this ref, and `git log` against a ref that does not exist fails silently
3338    /// and reads as "no commits". A checkout whose `origin/main` was never
3339    /// fetched would report every implementation as abandoned and throw the
3340    /// work away.
3341    pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
3342        let remote = format!("origin/{base}");
3343        if self.rev_exists(cwd, &remote) {
3344            return remote;
3345        }
3346        if self.rev_exists(cwd, base) {
3347            logdim!("origin/{base} does not resolve, comparing against local {base}");
3348            return base.to_string();
3349        }
3350        logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
3351        remote
3352    }
3353
3354    fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
3355        let spec = format!("{refname}^{{commit}}");
3356        !self
3357            .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
3358            .trim()
3359            .is_empty()
3360    }
3361
3362    pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
3363        let range = format!("{}..HEAD", self.base_ref(cwd, base));
3364        !self
3365            .git_try_at(Some(cwd), &["log", &range, "--oneline"])
3366            .trim()
3367            .is_empty()
3368    }
3369
3370    fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
3371        let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
3372        Ok(found.lines().any(|line| line.trim() == refname))
3373    }
3374
3375    fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
3376        let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
3377        count.trim().parse::<usize>().map_err(|e| {
3378            spar_err!(
3379                "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
3380                count.trim()
3381            )
3382        })
3383    }
3384
3385    pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
3386        let remote = format!("refs/remotes/origin/{base}");
3387        if self.exact_ref_exists_checked(cwd, &remote)? {
3388            return Ok(remote);
3389        }
3390        let local = format!("refs/heads/{base}");
3391        if self.exact_ref_exists_checked(cwd, &local)? {
3392            return Ok(local);
3393        }
3394        bail!("neither origin/{base} nor local branch {base} resolves")
3395    }
3396
3397    pub(crate) fn commit_count_checked(
3398        &self,
3399        cwd: &Path,
3400        refname: &str,
3401        base: &str,
3402    ) -> Result<usize> {
3403        let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
3404        let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
3405        count.trim().parse::<usize>().map_err(|e| {
3406            spar_err!(
3407                "git returned an invalid commit count for {range}: {:?} ({e})",
3408                count.trim()
3409            )
3410        })
3411    }
3412
3413    pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
3414        Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
3415    }
3416
3417    pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
3418        let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
3419        let head = head.trim().to_string();
3420        if head.is_empty() {
3421            bail!("git returned an empty HEAD for {}", cwd.display());
3422        }
3423        Ok(head)
3424    }
3425
3426    /// Whether a recorded SPAR branch's exact tip is retained by a pull request.
3427    ///
3428    /// Pull request head refs remain available after close or merge, so this is
3429    /// stronger than requiring an open pull request or a live remote branch.
3430    pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
3431        let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
3432        self.local_branch_is_preserved(branch.trim())
3433    }
3434
3435    /// Whether a recorded local branch's exact tip is retained by its pull
3436    /// request head, regardless of which branch is currently checked out.
3437    pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
3438        let known = self.known_branches();
3439        let Some(record) = known.get(branch) else {
3440            return Ok(false);
3441        };
3442        self.branch_is_preserved_checked(branch, record)
3443    }
3444
3445    /// Whether tracked, staged, or non-ignored untracked files are uncommitted.
3446    pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
3447        has_uncommitted_work(cwd)
3448    }
3449
3450    /// Whether removing a worktree would delete any local file Git does not
3451    /// reproduce from its commits, including ignored untracked files.
3452    fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
3453        repository_has_recoverable_work_with(cwd, true, disposable)
3454    }
3455
3456    /// Record ignored artifacts that existed before an editing call.
3457    pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
3458        let attributes = attribute_state(cwd)?;
3459        Ok(WorktreeBaseline {
3460            attributes,
3461            ignored_untracked: ignored_untracked_state(cwd)?,
3462            git_state: safe_git_state(cwd)?,
3463        })
3464    }
3465
3466    /// Capture the Git state of a checkout intended to remain read only while
3467    /// external commands inspect it.
3468    pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
3469        let attributes = attribute_state(cwd)?;
3470        Ok(WorktreeCheckpoint {
3471            path: std::fs::canonicalize(cwd)
3472                .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
3473            attributes,
3474            git_state: safe_git_state(cwd)?,
3475            ignored_untracked: ignored_untracked_state(cwd)?,
3476        })
3477    }
3478
3479    /// Move the files read-only calls created in a review checkout out of it,
3480    /// into `.spar/scratch/pr-<number>-<seconds>/` under the same paths, so the
3481    /// checkout matches its checkpoint again.
3482    ///
3483    /// A reviewer told to keep its scratch in the temporary directory still
3484    /// writes a repro test where the project's test runner will find it, and
3485    /// holding that against the review threw away everything both reviewers
3486    /// had done. What a review judges is the tracked tree, which
3487    /// `require_unchanged_worktree` still compares as strictly as ever, along
3488    /// with every untracked file that was there before. Only a file that was
3489    /// not is moved, and it is moved rather than deleted: it may be the
3490    /// reproduction a finding rests on.
3491    ///
3492    /// Best effort. Whatever this cannot move stays where it is, and the check
3493    /// that follows refuses it exactly as before.
3494    pub(crate) fn set_aside_review_scratch(
3495        &self,
3496        cwd: &Path,
3497        checkpoint: &WorktreeCheckpoint,
3498        number: i64,
3499    ) {
3500        let Ok(after) = probe_again_on_failure(|| ignored_untracked_state(cwd)) else {
3501            return;
3502        };
3503        let created: Vec<PathBuf> = checkpoint
3504            .ignored_untracked
3505            .new_ordinary_paths(&after)
3506            .into_iter()
3507            .filter(|path| {
3508                std::fs::symlink_metadata(cwd.join(path))
3509                    .is_ok_and(|meta| meta.is_file() || meta.file_type().is_symlink())
3510            })
3511            .collect();
3512        if created.is_empty() {
3513            return;
3514        }
3515        let shelf = match self.scratch_shelf(number) {
3516            Ok(shelf) => shelf,
3517            Err(e) => {
3518                logdim!("could not make room to move a reviewer's files aside: {e}");
3519                return;
3520            }
3521        };
3522        let mut moved = Vec::new();
3523        for path in created {
3524            let to = shelf.join(&path);
3525            let result = to
3526                .parent()
3527                .map_or(Ok(()), std::fs::create_dir_all)
3528                .and_then(|()| std::fs::rename(cwd.join(&path), &to));
3529            match result {
3530                Ok(()) => moved.push(path),
3531                Err(e) => logdim!(
3532                    "could not move {} out of the review checkout: {e}",
3533                    cwd.join(&path).display()
3534                ),
3535            }
3536        }
3537        if !moved.is_empty() {
3538            logwarn!(
3539                "PR #{number}: a reviewer left {} file(s) in the review checkout: {}. They were \
3540                 moved to {} and the review went on.",
3541                moved.len(),
3542                list_paths(&moved),
3543                shelf.display()
3544            );
3545        }
3546    }
3547
3548    /// A directory of its own under `.spar/scratch` for what one review phase
3549    /// left behind, so two phases never write over each other's files.
3550    fn scratch_shelf(&self, number: i64) -> Result<PathBuf> {
3551        let scratch = self.root.join(STATE_DIR).join("scratch");
3552        std::fs::create_dir_all(&scratch)
3553            .map_err(|e| spar_err!("could not create {}: {e}", scratch.display()))?;
3554        let stem = format!("pr-{number}-{}", crate::clock::now_secs());
3555        for serial in 0..1000 {
3556            let name = match serial {
3557                0 => stem.clone(),
3558                n => format!("{stem}-{n}"),
3559            };
3560            let shelf = scratch.join(name);
3561            match std::fs::create_dir(&shelf) {
3562                Ok(()) => return Ok(shelf),
3563                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
3564                Err(e) => return Err(spar_err!("could not create {}: {e}", shelf.display())),
3565            }
3566        }
3567        bail!(
3568            "could not choose a free directory under {}",
3569            scratch.display()
3570        )
3571    }
3572
3573    /// Require a read-only checkout to match a previously captured checkpoint.
3574    /// Any probe failure is an error because deletion cannot be proven safe.
3575    pub(crate) fn require_unchanged_worktree(
3576        &self,
3577        cwd: &Path,
3578        checkpoint: &WorktreeCheckpoint,
3579        label: &str,
3580    ) -> Result<()> {
3581        let resolved = std::fs::canonicalize(cwd).map_err(|e| {
3582            crate::error::SparError::uncertain_write(format!(
3583                "could not resolve the {label} at {} after inspection: {e}. It was kept.",
3584                cwd.display()
3585            ))
3586        })?;
3587        if resolved != checkpoint.path {
3588            return Err(uncertain_worktree_change(
3589                cwd,
3590                format!(
3591                    "the {label} moved from {} to {} during inspection. It was kept.",
3592                    checkpoint.path.display(),
3593                    resolved.display()
3594                ),
3595            ));
3596        }
3597        let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
3598            uncertain_worktree_change(
3599                cwd,
3600                format!(
3601                    "could not verify attribute files in the {label} at {}: {}. It was kept.",
3602                    cwd.display(),
3603                    e.last_line()
3604                ),
3605            )
3606        })?;
3607        if attributes != checkpoint.attributes {
3608            return Err(uncertain_worktree_change(
3609                cwd,
3610                format!(
3611                    "attribute files in the {label} at {} changed during inspection. It was \
3612                     kept for recovery.",
3613                    cwd.display()
3614                ),
3615            ));
3616        }
3617        let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
3618            uncertain_worktree_change(
3619                cwd,
3620                format!(
3621                    "could not verify the Git state of the {label} at {}: {}. It was kept.",
3622                    cwd.display(),
3623                    e.last_line()
3624                ),
3625            )
3626        })?;
3627        let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
3628            uncertain_worktree_change(
3629                cwd,
3630                format!(
3631                    "could not verify untracked files in the {label} at {}: {}. It was kept.",
3632                    cwd.display(),
3633                    e.last_line()
3634                ),
3635            )
3636        })?;
3637        let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
3638        // Said even when the inspection is accepted, for the same reason it is
3639        // said on an agent call: a path the project ignores is out of scope,
3640        // not unnoticed.
3641        note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
3642        let tracked = checkpoint.git_state.describe_difference(&git_state);
3643        let untracked = (!leavings.condemning.is_empty()).then(|| {
3644            format!(
3645                "untracked file(s) changed: {}",
3646                list_paths(&leavings.condemning)
3647            )
3648        });
3649        let Some(reason) = join_reasons(tracked, untracked) else {
3650            return Ok(());
3651        };
3652        Err(uncertain_worktree_change(
3653            cwd,
3654            format!(
3655                "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
3656                 for recovery.",
3657                cwd.display()
3658            ),
3659        ))
3660    }
3661
3662    /// Accept project-ignored files left by a successful editing call.
3663    /// Ordinary untracked files must already be represented by the commit.
3664    /// Acceptance does not authorize cleanup of the ignored files.
3665    pub(crate) fn refuse_new_ignored_files(
3666        &self,
3667        cwd: &Path,
3668        baseline: &WorktreeBaseline,
3669    ) -> Result<()> {
3670        self.allow_ignored_files(cwd, baseline).map(drop)
3671    }
3672
3673    /// Preserve unexpected output when the editing call did not finish.
3674    pub(crate) fn refuse_failed_edit_leavings(
3675        &self,
3676        cwd: &Path,
3677        baseline: &WorktreeBaseline,
3678    ) -> Result<()> {
3679        self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
3680            .map(drop)
3681    }
3682
3683    /// Reject ordinary untracked changes after a read-only call.
3684    pub(crate) fn refuse_read_only_leavings(
3685        &self,
3686        cwd: &Path,
3687        baseline: &WorktreeBaseline,
3688    ) -> Result<()> {
3689        self.refuse_changed_attributes(cwd, baseline)?;
3690        let after = ignored_untracked_state(cwd).map_err(|e| {
3691            uncertain_worktree_change(
3692                cwd,
3693                format!(
3694                    "could not verify untracked files in {} after a read-only call: {}. The \
3695                     worktree was kept for recovery.",
3696                    cwd.display(),
3697                    e.last_line()
3698                ),
3699            )
3700        })?;
3701        let leavings = baseline.ignored_untracked.read_only_leavings(&after);
3702        if leavings.condemning.is_empty() {
3703            return Ok(());
3704        }
3705        Err(uncertain_worktree_change(
3706            cwd,
3707            format!(
3708                "the read-only call left untracked file(s) in {} that the project does not \
3709                 ignore: {}. The worktree was kept for recovery.",
3710                cwd.display(),
3711                list_paths(&leavings.condemning)
3712            ),
3713        ))
3714    }
3715
3716    /// The ignored paths the check let through, for one report per attempt
3717    /// rather than one per check.
3718    fn allow_ignored_files(
3719        &self,
3720        cwd: &Path,
3721        baseline: &WorktreeBaseline,
3722    ) -> Result<AllowedArtifacts> {
3723        self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
3724    }
3725
3726    fn check_new_ignored_files(
3727        &self,
3728        cwd: &Path,
3729        baseline: &WorktreeBaseline,
3730        allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
3731    ) -> Result<AllowedArtifacts> {
3732        self.refuse_changed_attributes(cwd, baseline)?;
3733        let after = ignored_untracked_state(cwd).map_err(|e| {
3734            uncertain_worktree_change(
3735                cwd,
3736                format!(
3737                    "could not verify untracked files in {} after editing: {}. The worktree was \
3738                     kept for recovery.",
3739                    cwd.display(),
3740                    e.last_line()
3741                ),
3742            )
3743        })?;
3744        let changed = baseline.ignored_untracked.changed_paths(&after);
3745        if changed.is_empty() {
3746            return Ok(AllowedArtifacts::default());
3747        }
3748        let (ignored, changed): (Vec<_>, Vec<_>) = changed
3749            .into_iter()
3750            .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
3751        if changed.is_empty() {
3752            return Ok(AllowedArtifacts::split(ignored, &after));
3753        }
3754        let listed = list_paths(&changed);
3755        Err(uncertain_worktree_change(
3756            cwd,
3757            format!(
3758                "the editing call created or changed untracked or ignored file(s) in {} that \
3759                 cannot be represented by a managed commit: {listed}. The worktree was kept for \
3760                 recovery.",
3761                cwd.display()
3762            ),
3763        ))
3764    }
3765
3766    /// Existing untracked files belong to the checkout owner, even when a call
3767    /// also produces a valid tracked change. Refuse their modification or
3768    /// deletion before accepting the tracked result. Project-ignored files
3769    /// stay outside the commit, wherever the project puts them.
3770    pub(crate) fn refuse_changed_existing_untracked(
3771        &self,
3772        cwd: &Path,
3773        baseline: &WorktreeBaseline,
3774    ) -> Result<()> {
3775        self.check_changed_existing_untracked(cwd, baseline)
3776            .map(drop)
3777    }
3778
3779    fn allow_changed_ignored_artifacts(
3780        &self,
3781        cwd: &Path,
3782        baseline: &WorktreeBaseline,
3783    ) -> Result<AllowedArtifacts> {
3784        self.check_changed_existing_untracked(cwd, baseline)
3785    }
3786
3787    fn check_changed_existing_untracked(
3788        &self,
3789        cwd: &Path,
3790        baseline: &WorktreeBaseline,
3791    ) -> Result<AllowedArtifacts> {
3792        self.refuse_changed_attributes(cwd, baseline)?;
3793        let after = ignored_untracked_state(cwd).map_err(|e| {
3794            uncertain_worktree_change(
3795                cwd,
3796                format!(
3797                    "could not verify existing untracked files in {} after editing: {}. The \
3798                     worktree was kept for recovery.",
3799                    cwd.display(),
3800                    e.last_line()
3801                ),
3802            )
3803        })?;
3804        let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3805        if changed.is_empty() {
3806            return Ok(AllowedArtifacts::default());
3807        }
3808        let (ignored, changed): (Vec<_>, Vec<_>) = changed
3809            .into_iter()
3810            .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3811        if changed.is_empty() {
3812            return Ok(AllowedArtifacts::split(ignored, &after));
3813        }
3814        let listed = list_paths(&changed);
3815        Err(uncertain_worktree_change(
3816            cwd,
3817            format!(
3818                "the editing call changed or deleted existing untracked file(s) in {}: \
3819                 {listed}. The worktree was kept for recovery.",
3820                cwd.display()
3821            ),
3822        ))
3823    }
3824
3825    /// Refuse a byte or mode change that the index did not represent.
3826    ///
3827    /// Clean filters can normalize a working file back to its existing blob,
3828    /// and index flags can hide a change from porcelain status. Comparing the
3829    /// actual tracked files on both sides keeps those bytes from being treated
3830    /// as disposable just because Git has no diff for them.
3831    pub(crate) fn refuse_unrepresented_tracked_changes(
3832        &self,
3833        cwd: &Path,
3834        baseline: &WorktreeBaseline,
3835    ) -> Result<()> {
3836        self.refuse_changed_attributes(cwd, baseline)?;
3837        let after = safe_git_state(cwd).map_err(|e| {
3838            uncertain_worktree_change(
3839                cwd,
3840                format!(
3841                    "could not verify tracked files in {} after editing: {}. The worktree was \
3842                     kept for recovery.",
3843                    cwd.display(),
3844                    e.last_line()
3845                ),
3846            )
3847        })?;
3848        let mut changed = Vec::new();
3849        let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3850            uncertain_worktree_change(
3851                cwd,
3852                format!(
3853                    "could not record untracked files before verifying transformed content in {}: \
3854                     {}. The worktree was kept for recovery.",
3855                    cwd.display(),
3856                    e.last_line()
3857                ),
3858            )
3859        })?;
3860        let mut filter_was_run = false;
3861        let mut filter_problem = None;
3862        let mut repositories: BTreeSet<PathBuf> =
3863            baseline.git_state.repositories.keys().cloned().collect();
3864        repositories.extend(after.repositories.keys().cloned());
3865        'repositories: for repository_path in repositories {
3866            let before_repository = baseline.git_state.repositories.get(&repository_path);
3867            let after_repository = after.repositories.get(&repository_path);
3868            if before_repository.is_none() || after_repository.is_none() {
3869                changed.push(repository_path.clone());
3870                continue;
3871            }
3872            if before_repository.map(|repository| &repository.gitlinks)
3873                != after_repository.map(|repository| &repository.gitlinks)
3874            {
3875                changed.push(repository_path.join("<gitlinks>"));
3876            }
3877            let mut paths = BTreeSet::new();
3878            if let Some(repository) = before_repository {
3879                paths.extend(repository.tracked.keys().cloned());
3880            }
3881            if let Some(repository) = after_repository {
3882                paths.extend(repository.tracked.keys().cloned());
3883            }
3884            for path in paths {
3885                let before = before_repository.and_then(|repository| repository.tracked.get(&path));
3886                let current = after_repository.and_then(|repository| repository.tracked.get(&path));
3887                let worktree_changed =
3888                    before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
3889                let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
3890                    != current.map(|entry| (&entry.index_mode, &entry.index_oid));
3891                if !worktree_changed {
3892                    continue;
3893                }
3894                if !index_changed {
3895                    changed.push(repository_path.join(&path));
3896                    continue;
3897                }
3898                let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
3899                let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
3900                let Some(current_entry) = current else {
3901                    continue;
3902                };
3903                let Some(current_worktree) = current_worktree else {
3904                    continue;
3905                };
3906                let content_changed =
3907                    before_worktree.map(|file| file.content) != Some(current_worktree.content);
3908                let mode_changed = before_worktree.map(|file| file.mode.as_str())
3909                    != Some(current_worktree.mode.as_str());
3910                let repository = cwd.join(&repository_path);
3911                let represented_content = if content_changed {
3912                    filter_was_run = true;
3913                    let result =
3914                        filtered_index_content(&repository, &path, &current_entry.index_oid);
3915                    self.refuse_changed_attributes(cwd, baseline)?;
3916                    match result {
3917                        Ok(expected) => expected == current_worktree.content,
3918                        Err(error) => {
3919                            filter_problem = Some(format!(
3920                                "could not verify transformed content for {:?}: {}",
3921                                repository_path.join(&path),
3922                                error.last_line()
3923                            ));
3924                            false
3925                        }
3926                    }
3927                } else {
3928                    true
3929                };
3930                let represented_mode =
3931                    !mode_changed || current_worktree.mode == current_entry.index_mode;
3932                if !represented_content || !represented_mode {
3933                    changed.push(repository_path.join(&path));
3934                }
3935                if filter_problem.is_some() {
3936                    break 'repositories;
3937                }
3938            }
3939        }
3940        if filter_was_run {
3941            self.refuse_changed_attributes(cwd, baseline)?;
3942            let verified = safe_git_state(cwd).map_err(|e| {
3943                uncertain_worktree_change(
3944                    cwd,
3945                    format!(
3946                        "could not recheck tracked files after verifying transformed content in \
3947                         {}: {}. The worktree was kept for recovery.",
3948                        cwd.display(),
3949                        e.last_line()
3950                    ),
3951                )
3952            })?;
3953            let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3954                uncertain_worktree_change(
3955                    cwd,
3956                    format!(
3957                        "could not recheck untracked files after verifying transformed content \
3958                         in {}: {}. The worktree was kept for recovery.",
3959                        cwd.display(),
3960                        e.last_line()
3961                    ),
3962                )
3963            })?;
3964            if verified != after
3965                || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3966            {
3967                return Err(uncertain_worktree_change(
3968                    cwd,
3969                    "a content filter changed the worktree while SPAR verified the managed \
3970                     commit. The worktree was kept for recovery.",
3971                ));
3972            }
3973            self.refuse_changed_existing_untracked(cwd, baseline)?;
3974        }
3975        if let Some(problem) = filter_problem {
3976            return Err(uncertain_worktree_change(
3977                cwd,
3978                format!("{problem}. The worktree was kept for recovery."),
3979            ));
3980        }
3981        if changed.is_empty() {
3982            return Ok(());
3983        }
3984        let listed = list_paths(&changed);
3985        Err(uncertain_worktree_change(
3986            cwd,
3987            format!(
3988                "the editing call changed tracked working-file bytes, modes, repositories, or \
3989                 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3990                 recovery."
3991            ),
3992        ))
3993    }
3994
3995    pub(crate) fn refuse_changed_attributes(
3996        &self,
3997        cwd: &Path,
3998        baseline: &WorktreeBaseline,
3999    ) -> Result<()> {
4000        let after = attribute_state(cwd).map_err(|e| {
4001            uncertain_worktree_change(
4002                cwd,
4003                format!(
4004                    "could not verify attribute files in {} after editing: {}. The worktree was \
4005                     kept for recovery.",
4006                    cwd.display(),
4007                    e.last_line()
4008                ),
4009            )
4010        })?;
4011        if after == baseline.attributes {
4012            return Ok(());
4013        }
4014        Err(uncertain_worktree_change(
4015            cwd,
4016            format!(
4017                "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
4018                 refused to run a Git operation that could select a new external filter.",
4019                cwd.display()
4020            ),
4021        ))
4022    }
4023
4024    /// Commit a successful editing call from the trusted harness process.
4025    ///
4026    /// Editing sandboxes only need the working tree. They never need writable
4027    /// access to the repository's object database, refs, config, or hooks.
4028    pub(crate) fn commit_pending_changes(
4029        &self,
4030        cwd: &Path,
4031        baseline: &WorktreeBaseline,
4032        preferred_subject: &str,
4033        fallback_subject: &str,
4034    ) -> Result<bool> {
4035        let mut artifacts = IgnoredArtifacts::default();
4036        self.refuse_changed_attributes(cwd, baseline)?;
4037        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
4038        refuse_unsafe_index_flags(cwd)?;
4039        if !self.has_uncommitted_changes(cwd)? {
4040            artifacts.left(self.allow_ignored_files(cwd, baseline)?);
4041            artifacts.report(cwd);
4042            return Ok(false);
4043        }
4044        self.stage_managed_changes(cwd, baseline).map_err(|e| {
4045            e.with_message(format!(
4046                "could not stage changes in {}: {}",
4047                cwd.display(),
4048                e.last_line()
4049            ))
4050        })?;
4051        // Git ignore rules decide which files stay outside the commit.
4052        // Whether cleanup can delete them is a separate decision.
4053        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
4054        let changed_gitlinks = changed_staged_gitlinks(cwd)?;
4055        if !changed_gitlinks.is_empty() {
4056            let listed = list_paths(&changed_gitlinks);
4057            bail!(
4058                "the editing call added or changed a gitlink at {listed}. It was staged but not \
4059                 committed because the referenced repository objects might exist only inside \
4060                 this worktree. The worktree was kept for recovery."
4061            );
4062        }
4063        let mut subject = self.clean_title(preferred_subject)?;
4064        if subject.trim().is_empty() {
4065            subject = self.clean_title(fallback_subject)?;
4066        }
4067        self.commit_staged_changes(cwd, &subject).map_err(|e| {
4068            e.with_message(format!(
4069                "could not commit changes in {}: {}. The staged files were kept.",
4070                cwd.display(),
4071                e.last_line()
4072            ))
4073        })?;
4074        if has_tracked_or_staged_work(cwd)? {
4075            bail!(
4076                "the commit in {} left additional uncommitted files. They were kept for \
4077                 recovery.",
4078                cwd.display()
4079            );
4080        }
4081        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
4082        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
4083        artifacts.report(cwd);
4084        Ok(true)
4085    }
4086
4087    fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
4088        let after = ignored_untracked_state(cwd)?;
4089        self.git_at_without_automation(cwd, &["add", "-u"])?;
4090        let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
4091        if paths.is_empty() {
4092            return Ok(());
4093        }
4094        let mut input = Vec::new();
4095        for path in paths {
4096            input.extend(os_str_bytes(path.as_os_str())?);
4097            input.push(0);
4098        }
4099        let argv = git_without_automation_argv(&[
4100            "--literal-pathspecs",
4101            "add",
4102            "--pathspec-from-file=-",
4103            "--pathspec-file-nul",
4104        ]);
4105        proc::run_with_input_bytes(
4106            &argv,
4107            &self.git_opts(Some(cwd), true).stop_descendants(true),
4108            &input,
4109        )?;
4110        Ok(())
4111    }
4112
4113    /// Commit an index prepared by the parent without signing, hooks, or
4114    /// inherited repository automation.
4115    pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
4116        self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
4117            .map(|_| ())
4118    }
4119
4120    /// How many commits `refname` carries that the base does not.
4121    ///
4122    /// Counted from the commits themselves rather than from `commit_subjects`,
4123    /// which drops a commit whose message is empty. The guards in
4124    /// `worktree_add` decide whether to delete a branch on this number, and an
4125    /// empty message must not read as an empty branch.
4126    pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
4127        let range = format!("{}..{refname}", self.base_ref(cwd, base));
4128        self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
4129            .trim()
4130            .parse()
4131            .unwrap_or(0)
4132    }
4133
4134    /// One `hash subject` line per commit `refname` carries that the base does
4135    /// not, oldest first. For showing a person what is on a branch, so the
4136    /// hash keeps a commit with no message from listing as nothing.
4137    pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
4138        let range = format!("{}..{refname}", self.base_ref(cwd, base));
4139        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
4140            .lines()
4141            .map(str::to_string)
4142            .collect()
4143    }
4144
4145    /// The commits `later` carries that `earlier` does not, oldest first, when
4146    /// `earlier` is genuinely behind it.
4147    ///
4148    /// `None` when it is not an ancestor, which is not the same as nothing
4149    /// having landed. `rewrite_commits_if_needed` rewrites hashes from the first
4150    /// offending commit spar made onward, so a head recorded part way through a
4151    /// round can still be a readable object and no longer be on the branch. `git log` answers that
4152    /// with every commit on the branch, so without the check the one caller
4153    /// would report the whole branch as unread, which is the widest possible
4154    /// wrong answer.
4155    ///
4156    /// No `base_ref` resolution, unlike its neighbours: these are commits rather
4157    /// than branch names, and putting a sha through it logs a fallback line
4158    /// every time.
4159    pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
4160        let ancestor = self
4161            .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
4162            .is_ok();
4163        if !ancestor {
4164            return None;
4165        }
4166        let range = format!("{earlier}..{later}");
4167        Some(
4168            self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
4169                .lines()
4170                .map(str::to_string)
4171                .collect(),
4172        )
4173    }
4174
4175    /// The subjects of the commits `refname` carries that the base does not,
4176    /// oldest first.
4177    pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
4178        let range = format!("{}..{refname}", self.base_ref(cwd, base));
4179        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
4180            .lines()
4181            .map(str::trim)
4182            .filter(|line| !line.is_empty())
4183            .map(str::to_string)
4184            .collect()
4185    }
4186
4187    /// The paths this checkout changes relative to the base, sorted.
4188    ///
4189    /// A three dot range, matching `diff_stat`: what the branch did, not what
4190    /// the base has done since.
4191    ///
4192    /// `--no-renames` because a rename reported as its destination alone leaves
4193    /// the source out of the list, and a part carrying only the destination
4194    /// would be a copy. As a deletion and an addition it is two paths, which a
4195    /// part can carry together or leave to the leftover report.
4196    ///
4197    /// `-z` because without it git writes paths for display: anything
4198    /// non-ASCII comes back escaped and wrapped in quotes, and that string is
4199    /// not a path. A part built from one carries a pathspec matching no file,
4200    /// so the file never reaches the slice while every list still says the part
4201    /// took it. It also keeps a path with a space at either end intact.
4202    pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
4203        let range = format!("{}...HEAD", self.base_ref(cwd, base));
4204        self.git_try_at(
4205            Some(cwd),
4206            &["diff", "--name-only", "--no-renames", "-z", &range],
4207        )
4208        .split('\0')
4209        .filter(|path| !path.is_empty())
4210        .map(str::to_string)
4211        .collect()
4212    }
4213
4214    /// The lines a line comment can go on: the new side of each hunk of the
4215    /// branch's three dot diff, which is the diff GitHub shows for a pull
4216    /// request.
4217    ///
4218    /// Every flag that shapes the hunks is spelled out as git's own default,
4219    /// which is what GitHub shows, so a user's `diff.noprefix`, `diff.context`,
4220    /// `diff.interHunkContext`, `diff.algorithm` or `diff.submodule` cannot
4221    /// move a line comment onto a line GitHub does not show. `GIT_DIFF_OPTS`
4222    /// is emptied for the same reason: git reads it after the command line,
4223    /// so it would widen every hunk past `--unified=3`. `--no-ext-diff` and
4224    /// `--no-textconv` also matter for more than the shape: the branch being
4225    /// reviewed is somebody else's, and a diff driver or text converter would
4226    /// run a script it changed. A failure is no hunks, which only means every
4227    /// finding stays in the summary.
4228    pub fn diff_hunks(&self, cwd: &Path, base: &str) -> Hunks {
4229        let range = format!("{}...HEAD", self.base_ref(cwd, base));
4230        let argv = git_without_maintenance_argv(&[
4231            "-c",
4232            "core.quotePath=false",
4233            "diff",
4234            "--no-color",
4235            "--no-ext-diff",
4236            "--no-textconv",
4237            "--unified=3",
4238            "--inter-hunk-context=0",
4239            "--diff-algorithm=myers",
4240            "--indent-heuristic",
4241            "--submodule=short",
4242            "--find-renames",
4243            "--src-prefix=a/",
4244            "--dst-prefix=b/",
4245            &range,
4246        ]);
4247        let opts = self.git_opts(Some(cwd), false).env("GIT_DIFF_OPTS", "");
4248        new_side_hunks(&proc::run(&argv, &opts).unwrap_or_default())
4249    }
4250
4251    /// The files one commit range touched, for checking a claim against the
4252    /// diff rather than against the report that made it.
4253    pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
4254        self.git_try_at(
4255            Some(cwd),
4256            &[
4257                "diff",
4258                "--name-only",
4259                "--no-renames",
4260                "-z",
4261                &format!("{from}..{to}"),
4262            ],
4263        )
4264        .split('\0')
4265        .filter(|path| !path.is_empty())
4266        .map(str::to_string)
4267        .collect()
4268    }
4269
4270    /// Where `refname` left the base: the commit its own change is measured
4271    /// from, and the one a slice of that change has to be taken against.
4272    pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
4273        let base_ref = self.base_ref(cwd, base);
4274        let out = self
4275            .git_at(Some(cwd), &["merge-base", &base_ref, refname])
4276            .map_err(|e| {
4277                spar_err!(
4278                    "could not find where {refname} and {base_ref} diverged. {}",
4279                    e.last_line()
4280                )
4281            })?;
4282        let sha = out.trim().to_string();
4283        if sha.is_empty() {
4284            bail!("{refname} and {base_ref} share no history");
4285        }
4286        Ok(sha)
4287    }
4288
4289    pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
4290        let range = format!("{}...HEAD", self.base_ref(cwd, base));
4291        let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
4292        full.trim().to_string()
4293    }
4294
4295    /// Scrub commit messages that slipped past the prompt.
4296    ///
4297    /// `git filter-branch` calls back into this same binary, so there is no
4298    /// interpreter to find and no second copy of the rules to drift.
4299    ///
4300    /// `floor` is the head spar found when it started, and nothing at or below
4301    /// it is rewritten. The style gate is for what spar writes, and a person's
4302    /// commit message is not that: rewriting one changes a published SHA, which
4303    /// diverges the branch under somebody's own checkout and is exactly the
4304    /// history rewrite this design promises never to do. A `None` floor means
4305    /// every commit above the base is spar's own, which is true of a branch
4306    /// spar created in this invocation and of nothing else.
4307    pub fn rewrite_commits_if_needed(
4308        &self,
4309        cwd: &Path,
4310        base: &str,
4311        floor: Option<&str>,
4312    ) -> Result<()> {
4313        let base_ref = self.base_ref(cwd, base);
4314        let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
4315        self.report_commits_left_alone(cwd, &base_ref, floor);
4316
4317        let range = format!("{floor}..HEAD");
4318        let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
4319
4320        let offenders = raw
4321            .split('\x1e')
4322            .filter_map(|entry| entry.split_once('\0'))
4323            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4324            .count();
4325        if offenders == 0 {
4326            return Ok(());
4327        }
4328        logdim!("{offenders} commit message(s) violated style rules, rewriting");
4329
4330        let exe = self_binary()?;
4331        let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
4332
4333        let argv: Vec<String> = [
4334            "git",
4335            "filter-branch",
4336            "-f",
4337            "--msg-filter",
4338            &filter,
4339            &range,
4340        ]
4341        .iter()
4342        .map(|s| s.to_string())
4343        .collect();
4344        let opts = ExecOpts::new()
4345            .cwd(cwd)
4346            .check(false)
4347            .timeout_secs(600)
4348            .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
4349            .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
4350            .env(
4351                "SPAR_BAN_AI_ATTRIBUTION",
4352                bool_env(self.style.ban_ai_attribution),
4353            );
4354        let _ = proc::run(&argv, &opts);
4355
4356        let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
4357        if !style::violations(&after, &self.style).is_empty() {
4358            bail!(
4359                "commit messages still violate style rules after a rewrite in {}.",
4360                cwd.display()
4361            );
4362        }
4363        Ok(())
4364    }
4365
4366    /// Say out loud when a message spar will not touch breaks the style rules.
4367    ///
4368    /// Silence here would read as "there was nothing to scrub", and the point
4369    /// of the floor is that there was something and spar chose to leave it.
4370    fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
4371        if floor == base_ref {
4372            return;
4373        }
4374        let raw = self.git_try_at(
4375            Some(cwd),
4376            &[
4377                "log",
4378                &format!("{base_ref}..{floor}"),
4379                "--format=%H%x00%B%x1e",
4380            ],
4381        );
4382        let left: Vec<&str> = raw
4383            .split('\x1e')
4384            .filter_map(|entry| entry.split_once('\0'))
4385            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4386            .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
4387            .collect();
4388        if left.is_empty() {
4389            return;
4390        }
4391        logdim!(
4392            "{} commit message(s) already on the branch break the style rules and were left \
4393             alone: {}. spar does not rewrite commits it did not write.",
4394            left.len(),
4395            left.join(", ")
4396        );
4397    }
4398
4399    /// Push by explicit refspec from HEAD, leasing the head spar reviewed.
4400    ///
4401    /// A resumed PR is checked out under a local name (`pr-N`) that does not
4402    /// match its remote branch, so pushing by branch name would resolve the
4403    /// wrong local ref or fail outright.
4404    ///
4405    /// `expected` is the remote head spar last read. A bare `--force-with-lease`
4406    /// leases the implicit tracking ref instead, which is shared across every
4407    /// worktree and is updated by any `git fetch`. The editing agent is a real
4408    /// CLI with shell access, and fetching is a normal thing for it to do: one
4409    /// `git fetch` mid round moves the tracking ref to whatever the remote holds
4410    /// and the lease then passes against a head spar never reviewed, which
4411    /// silently drops somebody's commit. Naming the head makes the push refuse
4412    /// exactly that case.
4413    pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
4414        self.push_to(cwd, &PushTarget::Origin, branch, expected)
4415    }
4416
4417    /// `push`, to wherever the branch was first published.
4418    pub fn push_to(
4419        &self,
4420        cwd: &Path,
4421        target: &PushTarget,
4422        branch: &str,
4423        expected: Option<&str>,
4424    ) -> Result<()> {
4425        let expected = expected.map(str::trim).filter(|head| !head.is_empty());
4426        if let Some(head) = expected {
4427            self.refuse_push_that_drops(cwd, head)?;
4428        }
4429        let pushed = self
4430            .git_push(cwd, target, branch, expected)
4431            .map_err(|e| push_error(target, branch, &e));
4432        self.record_write(pushed)
4433    }
4434
4435    /// Publish a branch created in this invocation, from a fork if origin
4436    /// refuses it.
4437    ///
4438    /// Only a new branch can move. A pull request that already exists has its
4439    /// branch where it is, and pushing it anywhere else would not update it. A
4440    /// new one has no pull request yet, so a fork is just where it is opened
4441    /// from, which is how anybody without write access contributes.
4442    ///
4443    /// One write whichever way it goes. The refusal is not counted as a
4444    /// failure when the fork takes the branch: the run did what it set out to,
4445    /// and a failed write makes the exit status say otherwise.
4446    pub fn publish_new_branch(&self, cwd: &Path, branch: &str, base: &str) -> Result<PushTarget> {
4447        let origin = PushTarget::Origin;
4448        let refused = match self.git_push(cwd, &origin, branch, None) {
4449            Ok(()) => return self.record_write(Ok(origin)),
4450            Err(e) if self.fork_when_denied && push_denial(&e.to_string()).is_some() => e,
4451            Err(e) => {
4452                let err = push_error(&origin, branch, &e);
4453                let err = if push_denial(&e.to_string()).is_some() {
4454                    spar_err!(
4455                        "{err} Set fork_when_denied = true under [loop] to publish it from a fork \
4456                         instead."
4457                    )
4458                } else {
4459                    err
4460                };
4461                return self.record_write(Err(err));
4462            }
4463        };
4464        self.record_write(self.publish_on_fork(cwd, branch, base, &refused))
4465    }
4466
4467    fn publish_on_fork(
4468        &self,
4469        cwd: &Path,
4470        branch: &str,
4471        base: &str,
4472        refused: &crate::error::SparError,
4473    ) -> Result<PushTarget> {
4474        let fork = self.fork().map_err(|e| {
4475            spar_err!(
4476                "{} spar could not publish it from a fork instead: {}",
4477                push_error(&PushTarget::Origin, branch, refused),
4478                e.last_line()
4479            )
4480        })?;
4481        let lease = self.fork_branch_lease(&fork, branch, base)?;
4482        let refused = refused.to_string();
4483        log!(
4484            "{} Publishing {branch} from the fork {} instead.",
4485            push_denial(&refused).unwrap_or("origin refused the push."),
4486            fork.full_name
4487        );
4488        let target = PushTarget::Fork(fork);
4489        // Not `push_to`: a lease on a branch a closed pull request left behind
4490        // replaces it, and that branch was never this HEAD's to contain.
4491        self.git_push(cwd, &target, branch, lease.as_deref())
4492            .map_err(|e| push_error(&target, branch, &e))?;
4493        Ok(target)
4494    }
4495
4496    /// What the first push of `branch` to the fork leases against.
4497    ///
4498    /// Nothing when the fork has no such branch. Its tip when a pull request
4499    /// already holds every commit on it beyond `base`, which is what a closed
4500    /// pull request leaves, since GitHub keeps the branch on the fork. Anything
4501    /// else is work only that branch has, and is refused the way an issue
4502    /// branch on origin is.
4503    fn fork_branch_lease(&self, fork: &Fork, branch: &str, base: &str) -> Result<Option<String>> {
4504        let live_ref = format!("refs/heads/{branch}");
4505        let unreadable = |e: crate::error::SparError| {
4506            spar_err!(
4507                "could not check whether the fork {} already has {branch}. {}",
4508                fork.full_name,
4509                e.last_line()
4510            )
4511        };
4512        let listed = self
4513            .git(&["ls-remote", "--heads", &fork.url, &live_ref])
4514            .map_err(unreadable)?;
4515        let Some(tip) = remote_head_oid(&listed, &live_ref)? else {
4516            return Ok(None);
4517        };
4518        let local = format!("refs/spar/fork/{branch}");
4519        self.git(&[
4520            "fetch",
4521            "--no-tags",
4522            &fork.url,
4523            &format!("+{live_ref}:{local}"),
4524        ])
4525        .map_err(unreadable)?;
4526        let ahead = self.commit_count_checked(&self.root, &local, base);
4527        let held = matches!(ahead, Ok(0)) || self.pull_request_holds(branch, &local, base);
4528        self.git_try(&["update-ref", "-d", &local]);
4529        let ahead = ahead?;
4530        if !held {
4531            bail!(
4532                "the fork {} already has {branch} with {ahead} commit(s) that are not on {base}, \
4533                 and no pull request accounts for them. Publishing over it would lose that work.\n\
4534                 Delete it with `git push {} --delete {branch}` if it is no longer needed, then \
4535                 run spar again.",
4536                fork.full_name,
4537                fork.url
4538            );
4539        }
4540        Ok(Some(tip))
4541    }
4542
4543    fn git_push(
4544        &self,
4545        cwd: &Path,
4546        target: &PushTarget,
4547        branch: &str,
4548        expected: Option<&str>,
4549    ) -> Result<()> {
4550        let refspec = format!("HEAD:refs/heads/{branch}");
4551        let lease = match (expected, target) {
4552            (Some(head), _) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
4553            // A fork is pushed to by URL, so there is no tracking ref for a
4554            // bare lease to read. Leasing against nothing makes the first push
4555            // a create that never moves a branch already there.
4556            (None, PushTarget::Fork(_)) => format!("--force-with-lease=refs/heads/{branch}:"),
4557            (None, PushTarget::Origin) => "--force-with-lease".to_string(),
4558        };
4559        self.git_at(Some(cwd), &["push", &lease, target.remote(), &refspec])
4560            .map(|_| ())
4561    }
4562
4563    /// The authenticated user's fork of this repository, made if there is none.
4564    ///
4565    /// GitHub answers a request to fork with the fork that already exists, so
4566    /// asking is safe either way, but it is a write and it is only made when
4567    /// looking found nothing. A new fork is copied in the background, so it is
4568    /// waited for before anything is pushed to it.
4569    fn fork(&self) -> Result<Fork> {
4570        match self.existing_fork() {
4571            Ok(Some(fork)) => return Ok(fork),
4572            Ok(None) => {}
4573            Err(e) => logdim!("{e} Asking GitHub to fork it, which returns any fork there is."),
4574        }
4575        let answer = self
4576            .gh(&["api", "-X", "POST", "repos/{owner}/{repo}/forks"])
4577            .map_err(|e| spar_err!("could not fork this repository. {}", e.last_line()))?;
4578        let fork = fork_from_api(&answer, &self.origin_push_url())?;
4579        self.wait_for_fork(&fork)?;
4580        Ok(self.fork.get_or_init(|| fork).clone())
4581    }
4582
4583    /// The authenticated user's fork of this repository, if there is one,
4584    /// found without making one.
4585    fn existing_fork(&self) -> Result<Option<Fork>> {
4586        if let Some(fork) = self.fork.get() {
4587            return Ok(Some(fork.clone()));
4588        }
4589        let answer = self
4590            .gh(&[
4591                "api",
4592                "graphql",
4593                "-F",
4594                "owner={owner}",
4595                "-F",
4596                "name={repo}",
4597                "-f",
4598                "query=query($owner: String!, $name: String!) { repository(owner: $owner, name: \
4599                 $name) { forks(first: 1, affiliations: [OWNER]) { nodes { nameWithOwner owner { \
4600                 login } sshUrl url } } } }",
4601                "--jq",
4602                ".data.repository.forks.nodes",
4603            ])
4604            .map_err(|e| {
4605                spar_err!(
4606                    "could not look for a fork of this repository. {}",
4607                    e.last_line()
4608                )
4609            })?;
4610        let found = fork_from_listing(&answer, &self.origin_push_url())?;
4611        Ok(found.map(|fork| self.fork.get_or_init(|| fork).clone()))
4612    }
4613
4614    /// Whether GitHub says the authenticated user cannot push here. False
4615    /// when it cannot say, so an unanswered question skips the early check
4616    /// rather than guessing.
4617    fn origin_refuses_push(&self) -> bool {
4618        self.gh_try(&["api", "repos/{owner}/{repo}", "--jq", ".permissions.push"])
4619            .trim()
4620            == "false"
4621    }
4622
4623    fn origin_push_url(&self) -> String {
4624        self.git_try(&["remote", "get-url", "--push", "origin"])
4625    }
4626
4627    /// Where spar can push a pull request's branch: origin for a branch in
4628    /// this repository, the fork for one on the authenticated user's own fork,
4629    /// such as one spar opened after origin refused it, and nowhere for
4630    /// anybody else's.
4631    pub fn pr_push_target(&self, pr: &PrView) -> Option<PushTarget> {
4632        if !pr.is_cross_repository {
4633            return Some(PushTarget::Origin);
4634        }
4635        let viewer = self.viewer_login().ok()?;
4636        own_fork_target(pr, viewer, &self.origin_push_url())
4637    }
4638
4639    fn wait_for_fork(&self, fork: &Fork) -> Result<()> {
4640        const TRIES: u32 = 30;
4641        let mut tries = 1;
4642        loop {
4643            match self.git(&["ls-remote", &fork.url, "HEAD"]) {
4644                Ok(_) => return Ok(()),
4645                Err(e) if tries >= TRIES => bail!(
4646                    "the fork {} could not be reached after {} seconds. {}",
4647                    fork.full_name,
4648                    TRIES * 2,
4649                    e.last_line()
4650                ),
4651                Err(_) => {
4652                    tries += 1;
4653                    std::thread::sleep(std::time::Duration::from_secs(2));
4654                }
4655            }
4656        }
4657    }
4658
4659    /// Refuse a push whose HEAD does not contain the head spar reviewed.
4660    ///
4661    /// The lease answers the remote moving. This answers the local side moving:
4662    /// an agent that reset or rebased the worktree leaves a HEAD that would
4663    /// publish over the reviewed head rather than on top of it, and the lease
4664    /// cannot tell the difference because the remote is exactly where spar left
4665    /// it.
4666    fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
4667        let head = self.head_oid_checked(cwd)?;
4668        if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
4669            return Ok(());
4670        }
4671        bail!(
4672            "refusing to push {head} because it does not contain the reviewed head {expected}. \
4673             The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
4674             the intended commits on top of it before resuming.",
4675            cwd.display()
4676        )
4677    }
4678
4679    /// Create one remote branch for a split without ever moving an existing ref.
4680    ///
4681    /// `worktree_for_split` chooses a name that is free locally and on origin,
4682    /// but another writer can still take it before the push. An empty expected
4683    /// value in the lease makes this an atomic create: it creates an absent ref,
4684    /// accepts an identical ref as a no-op, and never moves an existing ref.
4685    /// The shared `push` method cannot be used because its lease permits
4686    /// updating a ref fetched earlier.
4687    pub fn push_split_branch(
4688        &self,
4689        cwd: &Path,
4690        branch: &str,
4691    ) -> std::result::Result<(), SplitPushError> {
4692        let remote_ref = format!("refs/heads/{branch}");
4693        let lease = format!("--force-with-lease={remote_ref}:");
4694        let refspec = format!("HEAD:{remote_ref}");
4695        let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
4696            Ok(_) => Ok(()),
4697            Err(push_error) => {
4698                let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
4699                let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
4700                reconcile_failed_split_push(branch, push_error, local, remote)
4701            }
4702        };
4703        self.record_write(result)
4704    }
4705
4706    // -- gh ---------------------------------------------------------------
4707
4708    pub fn gh(&self, args: &[&str]) -> Result<String> {
4709        self.gh_at(None, args)
4710    }
4711
4712    pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
4713        let mut argv = vec!["gh".to_string()];
4714        argv.extend(args.iter().map(|s| s.to_string()));
4715        proc::run(
4716            &argv,
4717            &ExecOpts::new()
4718                .cwd(cwd.unwrap_or(&self.root))
4719                .timeout_secs(300),
4720        )
4721    }
4722
4723    /// Run gh with something on its stdin.
4724    ///
4725    /// A tracker body is far too long to pass on argv, and `--body-file -` is
4726    /// how gh takes one. `proc::exec` already wires the pipe, so this is a
4727    /// sibling of `gh_at` rather than anything new.
4728    pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
4729        let mut argv = vec!["gh".to_string()];
4730        argv.extend(args.iter().map(|s| s.to_string()));
4731        proc::run(
4732            &argv,
4733            &ExecOpts::new()
4734                .cwd(&self.root)
4735                .timeout_secs(300)
4736                .stdin(stdin),
4737        )
4738    }
4739
4740    pub fn gh_try(&self, args: &[&str]) -> String {
4741        let mut argv = vec!["gh".to_string()];
4742        argv.extend(args.iter().map(|s| s.to_string()));
4743        proc::run(
4744            &argv,
4745            &ExecOpts::new()
4746                .cwd(&self.root)
4747                .check(false)
4748                .timeout_secs(300),
4749        )
4750        .unwrap_or_default()
4751    }
4752
4753    /// `gh api -i`: the status line and headers with the body, and the exit
4754    /// code ignored on purpose. A `304 Not Modified`, which is the answer a
4755    /// conditional poll wants, makes gh exit 1 while printing exactly what is
4756    /// needed. Only a spawn failure or a timeout is an error here.
4757    pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
4758        let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
4759        for (name, value) in headers {
4760            argv.push("-H".to_string());
4761            argv.push(format!("{name}: {value}"));
4762        }
4763        argv.push(endpoint.to_string());
4764        let out = proc::exec(
4765            &argv,
4766            &ExecOpts::new()
4767                .cwd(&self.root)
4768                .check(false)
4769                .timeout_secs(300),
4770        )?;
4771        parse_api_response(&out.stdout)
4772            .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
4773    }
4774
4775    /// Every open pull request, with what a sweep needs to know about each.
4776    pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
4777        let text = self.gh(&[
4778            "pr",
4779            "list",
4780            "--state",
4781            "open",
4782            "--limit",
4783            "500",
4784            "--json",
4785            PR_SWEEP_FIELDS,
4786        ])?;
4787        serde_json::from_str(&text)
4788            .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
4789    }
4790
4791    /// One pull request, open or not, with what a sweep needs to know.
4792    pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
4793        let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
4794        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4795    }
4796
4797    /// Whether `login` is in a team, named `org/slug` as gh gives a team in
4798    /// `reviewRequests`. GitHub answers 404 for a team the login is not in, and
4799    /// a team the viewer is in is one it can always see. None when GitHub did
4800    /// not answer either way.
4801    pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
4802        // A team asked for a review belongs to the repository's own owner.
4803        let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
4804        if slug.is_empty() || login.is_empty() {
4805            return None;
4806        }
4807        let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
4808        let resp = self.gh_api_raw(&path, &[]).ok()?;
4809        match resp.status {
4810            200 => {
4811                let body: Value = serde_json::from_str(&resp.body).ok()?;
4812                Some(body.get("state")?.as_str()? == "active")
4813            }
4814            404 => Some(false),
4815            _ => None,
4816        }
4817    }
4818
4819    /// Every review request made on a pull request, oldest first. None when
4820    /// GitHub did not answer.
4821    ///
4822    /// A review spar posts as a comment leaves the request it answered open,
4823    /// so `reviewRequests` alone cannot tell that request from one made after
4824    /// the review. When it was made can.
4825    pub fn review_requests_made(&self, number: i64) -> Option<Vec<crate::model::ReviewRequested>> {
4826        let text = self
4827            .gh(&[
4828                "api",
4829                "graphql",
4830                "-F",
4831                "owner={owner}",
4832                "-F",
4833                "repo={repo}",
4834                "-F",
4835                &format!("number={number}"),
4836                "-f",
4837                &format!("query={REQUESTS_QUERY}"),
4838            ])
4839            .ok()?;
4840        parse_review_requests(&text)
4841    }
4842
4843    /// The open issues assigned to `login`, with when each assignment was
4844    /// made. GitHub raises no notification for the viewer's own actions, so
4845    /// this is the only way `spar listen` learns that somebody assigned an
4846    /// issue to themselves.
4847    pub fn sweep_assigned_issues(&self, login: &str) -> Result<Vec<crate::model::AssignedIssue>> {
4848        let text = self.gh(&[
4849            "api",
4850            "graphql",
4851            "-F",
4852            "owner={owner}",
4853            "-F",
4854            "repo={repo}",
4855            "-f",
4856            &format!("login={login}"),
4857            "-f",
4858            &format!("query={}", assigned_issues_query()),
4859        ])?;
4860        parse_assigned_issues(&text)
4861            .ok_or_else(|| spar_err!("unexpected shape for the issues assigned to {login}"))
4862    }
4863
4864    /// Who issue `number` is assigned to now, and when each assignment was
4865    /// made.
4866    pub fn issue_assignments(&self, number: i64) -> Result<crate::model::AssignedIssue> {
4867        let text = self.gh(&[
4868            "api",
4869            "graphql",
4870            "-F",
4871            "owner={owner}",
4872            "-F",
4873            "repo={repo}",
4874            "-F",
4875            &format!("number={number}"),
4876            "-f",
4877            &format!("query={}", issue_assignments_query()),
4878        ])?;
4879        parse_issue_assignments(&text)
4880            .ok_or_else(|| spar_err!("unexpected shape for the assignments on #{number}"))
4881    }
4882
4883    /// How many commits lie between two SHAs, when GitHub will say.
4884    pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
4885        if from.is_empty() || to.is_empty() {
4886            return None;
4887        }
4888        let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
4889        self.gh_try(&["api", &path, "--jq", ".total_commits"])
4890            .trim()
4891            .parse()
4892            .ok()
4893    }
4894
4895    /// `owner/name`, for the line that says what is being watched.
4896    pub fn repo_name(&self) -> Option<String> {
4897        let name = self.gh_try(&[
4898            "repo",
4899            "view",
4900            "--json",
4901            "nameWithOwner",
4902            "--jq",
4903            ".nameWithOwner",
4904        ]);
4905        let name = name.trim();
4906        (!name.is_empty()).then(|| name.to_string())
4907    }
4908
4909    pub fn listen_state_path(&self) -> PathBuf {
4910        self.root.join(STATE_DIR).join("state").join("listen.json")
4911    }
4912
4913    pub fn listen_lock_path(&self) -> PathBuf {
4914        self.root.join(STATE_DIR).join("state").join("listen.lock")
4915    }
4916
4917    /// The login `gh` is authenticated as.
4918    ///
4919    /// A hard error, never a degradation. Everything spar wrote has to be
4920    /// excluded from what it answers, and custody cannot be read from git
4921    /// authorship, so this is the only thing that tells spar's own comments
4922    /// from somebody else's. Without it the failure is not "answers a bit too
4923    /// much", it is a thread where spar answers itself until somebody notices.
4924    ///
4925    /// Not cached on disk: `gh auth switch` between runs would make a stored
4926    /// answer wrong in exactly the way that produces that thread.
4927    pub fn viewer_login(&self) -> Result<&str> {
4928        if let Some(login) = self.viewer.get() {
4929            return Ok(login);
4930        }
4931        let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
4932        let login = if !rest.trim().is_empty() {
4933            rest.trim().to_string()
4934        } else {
4935            // A token that cannot read /user can still answer for itself in
4936            // GraphQL, which is the case on some Enterprise installs.
4937            self.gh(&[
4938                "api",
4939                "graphql",
4940                "-f",
4941                "query={ viewer { login } }",
4942                "--jq",
4943                ".data.viewer.login",
4944            ])
4945            .map_err(|e| {
4946                spar_err!(
4947                    "could not find out who `gh` is authenticated as, so spar cannot tell its \
4948                     own comments from anybody else's. {}\nRun `gh auth status`.",
4949                    e.last_line()
4950                )
4951            })?
4952            .trim()
4953            .to_string()
4954        };
4955        if login.is_empty() {
4956            bail!("`gh` reported an empty login. Run `gh auth status`.");
4957        }
4958        Ok(self.viewer.get_or_init(|| login))
4959    }
4960
4961    /// One issue as it stands, open or closed.
4962    ///
4963    /// `fetch_issues` reads a queue to work: it drops a closed issue and fails
4964    /// when nothing survives. Both are wrong for reading one issue back, where
4965    /// closed is an answer and the empty case cannot arise.
4966    pub fn read_issue(&self, number: i64) -> Result<Issue> {
4967        let text = self
4968            .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4969            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4970        serde_json::from_str(&text)
4971            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
4972    }
4973
4974    pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
4975        let mut issues = Vec::new();
4976        for number in numbers {
4977            let text = self
4978                .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4979                .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4980            let issue: Issue = serde_json::from_str(&text)
4981                .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4982            if issue.is_closed() {
4983                crate::log!("issue #{number} is closed, skipping");
4984                continue;
4985            }
4986            issues.push(issue);
4987        }
4988        if issues.is_empty() {
4989            bail!("no open issues to work on");
4990        }
4991        Ok(issues)
4992    }
4993
4994    /// Open items, lowest numbered first, from `min_number` upward.
4995    ///
4996    /// The floor exists because a long lived repository accumulates a tail of
4997    /// old issues nobody is going to get to, and taking the lowest numbered
4998    /// open items means walking straight into them.
4999    fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
5000        #[derive(Deserialize)]
5001        struct Row {
5002            number: i64,
5003        }
5004        let text = self.gh(&[
5005            kind,
5006            "list",
5007            "--state",
5008            "open",
5009            "--limit",
5010            &FETCH_CEILING.to_string(),
5011            "--json",
5012            "number",
5013        ])?;
5014        let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
5015        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
5016        numbers.sort_unstable();
5017
5018        let noun = if kind == "issue" { "issues" } else { "PRs" };
5019        let found = numbers.len();
5020        if min_number > 0 {
5021            numbers.retain(|n| *n >= min_number);
5022            let skipped = found - numbers.len();
5023            if skipped > 0 {
5024                crate::log!("{skipped} open {noun} below #{min_number} skipped");
5025            }
5026        }
5027        if found >= FETCH_CEILING {
5028            crate::log!(
5029                "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
5030                 considered."
5031            );
5032        }
5033        if numbers.len() > limit {
5034            crate::log!(
5035                "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
5036                 explicitly.",
5037                numbers.len()
5038            );
5039            numbers.truncate(limit);
5040        }
5041        Ok(numbers)
5042    }
5043
5044    /// Open issues, lowest numbered first. `gh issue list` excludes PRs.
5045    pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
5046        self.open_numbers("issue", limit, min_number)
5047    }
5048
5049    pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
5050        self.open_numbers("pr", limit, min_number)
5051    }
5052
5053    pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
5054        self.branch_prs(branch, "open").into_iter().next()
5055    }
5056
5057    /// The open pull request for a branch, preserving a failed lookup as an
5058    /// error when the caller is deciding whether a write already landed.
5059    pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
5060        let text = self.gh(&[
5061            "pr",
5062            "list",
5063            "--head",
5064            branch,
5065            "--base",
5066            base,
5067            "--state",
5068            "open",
5069            "--json",
5070            "number,url,title,baseRefName",
5071        ])?;
5072        pr_for_base(&text, branch, base)
5073    }
5074
5075    /// Every pull request opened from this branch, merged and closed ones
5076    /// included, because a commit is preserved by whichever one carries it and
5077    /// that is rarely the newest.
5078    fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
5079        self.branch_prs(branch, "all")
5080    }
5081
5082    fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
5083        let text = self.gh_try(&[
5084            "pr",
5085            "list",
5086            "--head",
5087            branch,
5088            "--state",
5089            state,
5090            "--json",
5091            "number,url,title",
5092        ]);
5093        serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
5094    }
5095
5096    /// Whether a number names an issue or a pull request.
5097    ///
5098    /// `gh issue view` happily returns a pull request when handed its number,
5099    /// so it cannot be used to tell them apart. The issues API carries both and
5100    /// marks a pull request with a `pull_request` key, which is definitive.
5101    pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
5102        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
5103        let text = self
5104            .gh(&[
5105                "api",
5106                &path,
5107                "--jq",
5108                "if .pull_request then \"pr\" else \"issue\" end",
5109            ])
5110            .map_err(|e| {
5111                spar_err!(
5112                    "no issue or pull request #{number} in this repository. {}",
5113                    e.last_line()
5114                )
5115            })?;
5116        match text.trim() {
5117            "pr" => Ok(ItemKind::Pr),
5118            "issue" => Ok(ItemKind::Issue),
5119            other => Err(spar_err!(
5120                "could not tell whether #{number} is an issue or a pull request (got {other:?})"
5121            )),
5122        }
5123    }
5124
5125    /// An open pull request that would close this issue, whoever opened it.
5126    ///
5127    /// spar's own branch naming is checked first because it is exact and cheap.
5128    /// Falling back to GitHub's own issue linkage is what lets spar pick up a
5129    /// pull request a person started on a branch named anything at all.
5130    ///
5131    /// An error rather than `None` when the question could not be asked. The
5132    /// two answers are not interchangeable: "no pull request exists" is the one
5133    /// that makes spar implement over the top of somebody's work and open a
5134    /// second pull request for the same issue.
5135    pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
5136        let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
5137        if let Some(pr) = mine {
5138            return Ok(Some(pr));
5139        }
5140        let listed = match self.gh(&[
5141            "pr",
5142            "list",
5143            "--state",
5144            "open",
5145            "--limit",
5146            &FETCH_CEILING.to_string(),
5147            "--json",
5148            "number,url,title,baseRefName,closingIssuesReferences",
5149        ]) {
5150            Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
5151                spar_err!("could not read the pull request list while looking for #{issue}")
5152            })?,
5153            Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
5154        };
5155        if listed.len() >= FETCH_CEILING {
5156            logwarn!(
5157                "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a                  pull request past it cannot be seen from here."
5158            );
5159        }
5160        let default = self.default_branch(base);
5161        if !base.eq_ignore_ascii_case(&default) {
5162            // GitHub populates closingIssuesReferences only for pull requests
5163            // targeting the default branch, so on any other base the linkage is
5164            // silence rather than an answer.
5165            logwarn!(
5166                "#{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."
5167            );
5168        }
5169        Ok(pick_linked_pr(&listed, issue))
5170    }
5171
5172    /// spar's own branch, with a failed lookup kept as a failure.
5173    fn pr_for_branch_failing_closed(
5174        &self,
5175        branch: &str,
5176        base: &str,
5177        issue: i64,
5178    ) -> Result<Option<PrRef>> {
5179        match self.try_pr_for_branch(branch, base) {
5180            Ok(found) => Ok(found),
5181            Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
5182        }
5183    }
5184
5185    /// What to do when GitHub would not say whether a pull request exists.
5186    ///
5187    /// An error, because "no pull request exists" is the answer that makes spar
5188    /// implement from scratch and open a second pull request over somebody's
5189    /// work. The one exception is a repository gh cannot reach at all: there is
5190    /// no pull request to collide with when there is no GitHub repository in
5191    /// play, and every later write would fail anyway. That case is a warning and
5192    /// a `None`, so a run against a plain git remote behaves as it always did.
5193    fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
5194        if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
5195            return Err(spar_err!(
5196                "could not check whether a pull request already exists for #{issue}: {}. \
5197                 Refusing to implement over the top of work that may already be open.",
5198                e.last_line()
5199            ));
5200        }
5201        logwarn!(
5202            "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
5203             whether a pull request already exists for it.",
5204            e.last_line()
5205        );
5206        Ok(())
5207    }
5208
5209    pub fn pr_view(&self, number: i64) -> Result<PrView> {
5210        let text = self.gh(&[
5211            "pr",
5212            "view",
5213            &number.to_string(),
5214            "--json",
5215            "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,\
5216             isCrossRepository,headRepositoryOwner,headRepository",
5217        ])?;
5218        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
5219    }
5220
5221    fn try_pr_state(&self, number: i64) -> Result<String> {
5222        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
5223        serde_json::from_str::<Value>(text.trim())
5224            .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
5225            .get("state")
5226            .and_then(Value::as_str)
5227            .map(str::to_string)
5228            .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
5229    }
5230
5231    pub fn pr_state(&self, number: i64) -> String {
5232        self.try_pr_state(number).unwrap_or_default()
5233    }
5234
5235    /// The commit currently exposed as a pull request's head.
5236    pub fn pr_head_oid(&self, number: i64) -> Result<String> {
5237        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
5238        let oid = serde_json::from_str::<Value>(&text)
5239            .ok()
5240            .and_then(|value| {
5241                value
5242                    .get("headRefOid")
5243                    .and_then(Value::as_str)
5244                    .map(str::trim)
5245                    .filter(|oid| !oid.is_empty())
5246                    .map(str::to_string)
5247            })
5248            .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
5249        Ok(oid)
5250    }
5251
5252    /// Where a pull request's head stands now: what GitHub's pull request
5253    /// names, and, for a branch in this repository, where `origin` has the
5254    /// branch. The two differ until GitHub catches up with a push.
5255    pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
5256        let pull = self.pr_head_oid(pr.number)?;
5257        let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
5258            None
5259        } else {
5260            let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
5261            let listed = self
5262                .git(&["ls-remote", "--heads", "origin", &remote_ref])
5263                .map_err(|e| {
5264                    spar_err!(
5265                        "could not read where {} is on origin: {}",
5266                        pr.head_ref_name.trim(),
5267                        e.last_line()
5268                    )
5269                })?;
5270            remote_head_oid(&listed, &remote_ref)?
5271        };
5272        Ok(LiveHead { pull, branch })
5273    }
5274
5275    /// Open a pull request from `branch`, published at `target`.
5276    ///
5277    /// The lookup that confirms it landed goes by branch name alone, which
5278    /// `gh pr list --head` matches on a fork's pull request too.
5279    pub fn create_pr(
5280        &self,
5281        cwd: &Path,
5282        branch: &str,
5283        target: &PushTarget,
5284        base: &str,
5285        title: &str,
5286        body: &str,
5287    ) -> Result<PrRef> {
5288        let title = self.record_failed_write(self.clean_title(title))?;
5289        let body = self.record_failed_write(self.clean(body))?;
5290        let head = target.head(branch);
5291        let mut argv = vec![
5292            "pr", "create", "--base", base, "--head", &head, "--title", &title, "--body", &body,
5293        ];
5294        if self.drafts != Drafts::Never {
5295            argv.push("--draft");
5296        }
5297        let created = self.gh_at(Some(cwd), &argv);
5298        let found = self.try_pr_for_branch(branch, base);
5299        self.record_write(reconcile_pr_creation(branch, created, found))
5300    }
5301
5302    pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
5303        let body = signed(&self.record_failed_write(self.clean(body))?);
5304        let comments = self.record_failed_write(self.try_issue_comments(number))?;
5305        if has_exact_comment(&comments, &body) {
5306            return Ok(());
5307        }
5308        let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
5309        let result = match posted {
5310            Ok(_) => Ok(()),
5311            Err(post_error) => {
5312                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5313            }
5314        };
5315        self.record_write(result)
5316    }
5317
5318    /// Whether the viewer wrote this pull request. GitHub refuses to let an
5319    /// author approve or request changes on their own, so a verdict on one can
5320    /// only ever be a comment.
5321    fn pr_is_own(&self, number: i64) -> Result<bool> {
5322        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
5323        let author = serde_json::from_str::<Value>(&text)
5324            .ok()
5325            .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
5326            .unwrap_or_default();
5327        let viewer = self.viewer_login()?.to_string();
5328        Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
5329    }
5330
5331    /// Submit a formal review, or a comment carrying the verdict's words when
5332    /// GitHub will not take the verdict: the viewer's own pull request, or no
5333    /// verdict at all.
5334    ///
5335    /// Mirrors `comment_pr` step for step: the style gate, the signature, a
5336    /// duplicate check against the viewer's own reviews, the write, and a read
5337    /// back on failure so an unverifiable write is reported as one.
5338    ///
5339    /// `commit` is the commit the reviewers read. GitHub attaches a review
5340    /// that names no commit to whatever the head is when it arrives, so a push
5341    /// made while a review ran would otherwise receive an approval nobody
5342    /// gave it. `gh pr review` cannot name one, hence the REST call.
5343    ///
5344    /// `comments` go on their lines in the same review, so they arrive with
5345    /// it or not at all. `body` goes with them, and may leave to them what
5346    /// they say. `full` says everything, and is what goes up when they do not.
5347    /// GitHub refuses the whole review over one line its diff does not show,
5348    /// so when it refused this one and the read back finds nothing landed, the
5349    /// review goes up again without them, with `full`. Any other failure is
5350    /// reconciled as a review without them would be, never retried: a gateway
5351    /// error can arrive while GitHub is still saving a review that will land.
5352    ///
5353    /// Either body standing on `commit` already is this review, so a rerun
5354    /// after the fallback landed does not post it again.
5355    pub fn submit_review(
5356        &self,
5357        number: i64,
5358        verdict: Option<crate::model::ReviewVerdict>,
5359        body: &str,
5360        full: &str,
5361        commit: &str,
5362        comments: &[InlineComment],
5363    ) -> Result<()> {
5364        let own = match verdict {
5365            Some(_) => self.record_failed_write(self.pr_is_own(number))?,
5366            None => false,
5367        };
5368        let prepare = |body: &str| {
5369            let text = match (verdict, own) {
5370                (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
5371                _ => body.to_string(),
5372            };
5373            self.record_failed_write(self.clean(&text))
5374                .map(|b| signed(&b))
5375        };
5376        let body = prepare(body)?;
5377        let full = prepare(full)?;
5378        let mut inline = Vec::with_capacity(comments.len());
5379        for c in comments {
5380            inline.push(InlineComment {
5381                body: signed(&self.record_failed_write(self.clean(&c.body))?),
5382                ..c.clone()
5383            });
5384        }
5385        let either = [body.as_str(), full.as_str()];
5386        let viewer = self.record_failed_write(self.viewer_login())?.to_string();
5387        let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
5388        if has_viewer_review(&reviews, &viewer, &either, commit) {
5389            return Ok(());
5390        }
5391        let event = match (verdict, own) {
5392            (Some(v), false) => v.api_event(),
5393            _ => "COMMENT",
5394        };
5395        let result = if inline.is_empty() {
5396            self.post_review(number, &full, &viewer, commit, event)
5397        } else {
5398            let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5399            let payload = review_payload(commit, event, &body, &inline).to_string();
5400            match self.gh_stdin(
5401                &["api", "-X", "POST", &path, "--input", "-", "--silent"],
5402                &payload,
5403            ) {
5404                Ok(_) => Ok(()),
5405                Err(post_error) if !refused(&post_error) => reconcile_review_post(
5406                    number,
5407                    &[&body],
5408                    &viewer,
5409                    commit,
5410                    post_error,
5411                    self.try_pr_reviews(number),
5412                ),
5413                Err(post_error) => match self.try_pr_reviews(number) {
5414                    Ok(reviews) if !has_viewer_review(&reviews, &viewer, &either, commit) => {
5415                        logwarn!(
5416                            "the review of #{number} with its line comments did not go up ({}), \
5417                             so it is going up with every finding in its body only",
5418                            post_error.last_line()
5419                        );
5420                        self.post_review(number, &full, &viewer, commit, event)
5421                    }
5422                    reviews => {
5423                        reconcile_review_post(number, &either, &viewer, commit, post_error, reviews)
5424                    }
5425                },
5426            }
5427        };
5428        self.record_write(result)
5429    }
5430
5431    /// A review with its body alone, read back if the post reports a failure.
5432    fn post_review(
5433        &self,
5434        number: i64,
5435        body: &str,
5436        viewer: &str,
5437        commit: &str,
5438        event: &str,
5439    ) -> Result<()> {
5440        let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5441        let posted = self.gh(&[
5442            "api",
5443            "-X",
5444            "POST",
5445            &path,
5446            "-f",
5447            &format!("commit_id={commit}"),
5448            "-f",
5449            &format!("event={event}"),
5450            "-f",
5451            &format!("body={body}"),
5452            "--silent",
5453        ]);
5454        match posted {
5455            Ok(_) => Ok(()),
5456            Err(post_error) => reconcile_review_post(
5457                number,
5458                &[body],
5459                viewer,
5460                commit,
5461                post_error,
5462                self.try_pr_reviews(number),
5463            ),
5464        }
5465    }
5466
5467    pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
5468        let body = signed(&self.record_failed_write(self.clean(body))?);
5469        let comments = self.record_failed_write(self.try_issue_comments(number))?;
5470        if has_exact_comment(&comments, &body) {
5471            return Ok(());
5472        }
5473        let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
5474        let result = match posted {
5475            Ok(_) => Ok(()),
5476            Err(post_error) => {
5477                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5478            }
5479        };
5480        self.record_write(result)
5481    }
5482
5483    /// Comment, then close as not planned.
5484    ///
5485    /// Only ever called when both agents independently declined the issue: one
5486    /// agent's opinion is not enough to close somebody's report.
5487    pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
5488        self.comment_issue(number, body)?;
5489        let n = number.to_string();
5490        let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
5491            Ok(_) => Ok(()),
5492            // Older gh builds do not take --reason.
5493            Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
5494                spar_err!(
5495                    "commented on #{number} but could not close it: {}",
5496                    e.last_line()
5497                )
5498            }),
5499        };
5500        self.record_write(closed)
5501    }
5502
5503    /// Replace an issue body, refusing unless it is still byte for byte what
5504    /// the caller read and validating only the fragment spar inserted.
5505    ///
5506    /// The only place spar rewrites text somebody else wrote, so the check is
5507    /// the whole point: an edit computed from a body that has since moved would
5508    /// silently delete whatever moved it. The caller decides whether another
5509    /// attempt is safe for its workflow.
5510    ///
5511    /// Deliberately not through `clean_issue_body`. The body is mostly a
5512    /// person's own prose, and the scrub would rewrite their punctuation while
5513    /// the length budget could truncate the end of a long report. `inserted` is
5514    /// the only text here spar is answerable for, so it still passes through the
5515    /// style gate. The full body travels over stdin because a tracker can be far
5516    /// too long for one argument.
5517    pub fn edit_issue_body(
5518        &self,
5519        number: i64,
5520        expected: &str,
5521        body: &str,
5522        inserted: &str,
5523    ) -> Result<()> {
5524        let cleaned = self.record_failed_write(self.clean(inserted))?;
5525        if cleaned.trim() != inserted.trim() {
5526            return self.record_failed_write(Err(spar_err!(
5527                "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
5528            )));
5529        }
5530        let current = self.record_failed_write(self.issue_body(number))?;
5531        if current != expected {
5532            return self.record_failed_write(Err(spar_err!(
5533                "the body of #{number} changed since it was read, so it was left alone rather \
5534                 than written over."
5535            )));
5536        }
5537        let edited = self.gh_stdin(
5538            &["issue", "edit", &number.to_string(), "--body-file", "-"],
5539            body,
5540        );
5541        let result = match edited {
5542            Ok(_) => Ok(()),
5543            Err(edit_error) => {
5544                reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
5545            }
5546        };
5547        self.record_write(result)
5548    }
5549
5550    /// One issue's body, exactly as GitHub holds it.
5551    pub fn issue_body(&self, number: i64) -> Result<String> {
5552        #[derive(Deserialize)]
5553        struct Row {
5554            #[serde(default)]
5555            body: Option<String>,
5556        }
5557        let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
5558        let row: Row = serde_json::from_str(text.trim())
5559            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
5560        Ok(row.body.unwrap_or_default())
5561    }
5562
5563    /// Every open issue with its title and body, in one call.
5564    ///
5565    /// For a screen that has to say something about each of twenty items before
5566    /// anything expensive happens. One call rather than one per issue.
5567    pub fn open_issue_rows(&self) -> Vec<Issue> {
5568        let text = self.gh_try(&[
5569            "issue",
5570            "list",
5571            "--state",
5572            "open",
5573            "--limit",
5574            &FETCH_CEILING.to_string(),
5575            "--json",
5576            "number,title,body,labels,state,stateReason,url",
5577        ]);
5578        serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
5579    }
5580
5581    /// Every open pull request with its size, in one call.
5582    pub fn open_pr_rows(&self) -> Vec<PrRow> {
5583        let text = self.gh_try(&[
5584            "pr",
5585            "list",
5586            "--state",
5587            "open",
5588            "--limit",
5589            &FETCH_CEILING.to_string(),
5590            "--json",
5591            "number,title,changedFiles,additions,deletions",
5592        ]);
5593        serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
5594    }
5595
5596    pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
5597        self.create_issue_apart_from(title, body, None)
5598    }
5599
5600    pub fn create_issue_apart_from(
5601        &self,
5602        title: &str,
5603        body: &str,
5604        apart_from: Option<i64>,
5605    ) -> Result<String> {
5606        let title = self.record_failed_write(self.clean_title(title))?;
5607        let body = self.record_failed_write(self.clean_issue_body(body))?;
5608        let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
5609        let result = match created {
5610            Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
5611            created => {
5612                let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
5613                reconcile_issue_creation(&title, created, found)
5614            }
5615        };
5616        self.record_write(result)
5617    }
5618}
5619
5620/// An issue that already covers what spar was about to file.
5621#[derive(Debug, Clone)]
5622pub struct ExistingIssue {
5623    pub number: i64,
5624    pub url: String,
5625    pub title: String,
5626    pub body: String,
5627    pub open: bool,
5628}
5629
5630impl Repo {
5631    pub(crate) fn try_exact_issue_apart_from(
5632        &self,
5633        title: &str,
5634        body: &str,
5635        apart_from: Option<i64>,
5636    ) -> Result<Option<ExistingIssue>> {
5637        #[derive(Deserialize)]
5638        #[serde(rename_all = "camelCase")]
5639        struct Row {
5640            number: i64,
5641            #[serde(default)]
5642            title: String,
5643            #[serde(default)]
5644            url: String,
5645            #[serde(default)]
5646            body: Option<String>,
5647            #[serde(default)]
5648            state: String,
5649        }
5650
5651        let text = self.gh(&[
5652            "issue",
5653            "list",
5654            "--state",
5655            "all",
5656            "--limit",
5657            "100",
5658            "--json",
5659            "number,title,url,body,state,stateReason",
5660        ])?;
5661        let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5662            .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
5663        Ok(rows
5664            .into_iter()
5665            .filter(|row| Some(row.number) != apart_from)
5666            .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
5667            .map(|row| ExistingIssue {
5668                number: row.number,
5669                url: row.url,
5670                title: row.title,
5671                body: row.body.unwrap_or_default(),
5672                open: row.state.eq_ignore_ascii_case("open"),
5673            }))
5674    }
5675
5676    /// An issue that already describes this defect, however it was worded.
5677    ///
5678    /// Exact title matching let duplicates through: two agents, or two runs a
5679    /// week apart, never word one defect identically. A real run filed two
5680    /// duplicates that way, and each had to be closed by hand afterwards.
5681    /// Titles alone are too thin to match on, so this compares titles and
5682    /// bodies together.
5683    pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
5684        self.find_similar_issue_apart_from(title, body, None)
5685    }
5686
5687    /// The same search, with one issue that cannot be its own duplicate.
5688    ///
5689    /// A tracker's body quotes every item in its checklist, so searching for an
5690    /// item's words matches the tracker before it matches anything else. That
5691    /// would link an item to the issue it is written in.
5692    /// The search a tracker runs, which wants closed issues too: a checklist
5693    /// item matching a closed issue is a done item that wants a link.
5694    pub fn find_similar_issue_apart_from(
5695        &self,
5696        title: &str,
5697        body: &str,
5698        apart_from: Option<i64>,
5699    ) -> Option<ExistingIssue> {
5700        self.try_similar_issue(title, body, apart_from, true)
5701            .ok()
5702            .flatten()
5703    }
5704
5705    /// The search a filing runs, over open issues only.
5706    pub fn try_find_similar_issue_apart_from(
5707        &self,
5708        title: &str,
5709        body: &str,
5710        apart_from: Option<i64>,
5711    ) -> Result<Option<ExistingIssue>> {
5712        self.try_similar_issue(title, body, apart_from, false)
5713    }
5714
5715    /// The same search, preserving lookup failure for a caller about to write.
5716    fn try_similar_issue(
5717        &self,
5718        title: &str,
5719        body: &str,
5720        apart_from: Option<i64>,
5721        include_closed: bool,
5722    ) -> Result<Option<ExistingIssue>> {
5723        #[derive(Deserialize)]
5724        #[serde(rename_all = "camelCase")]
5725        struct Row {
5726            number: i64,
5727            #[serde(default)]
5728            title: String,
5729            #[serde(default)]
5730            url: String,
5731            #[serde(default)]
5732            body: String,
5733            #[serde(default)]
5734            state: String,
5735        }
5736        if title.trim().is_empty() {
5737            return Ok(None);
5738        }
5739        // Search on the title's own words: GitHub's index is the cheap way to
5740        // narrow the field before comparing properly.
5741        let query: String = title
5742            .chars()
5743            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5744            .take(120)
5745            .collect();
5746        // Open issues only when something is about to be filed. A fuzzy match
5747        // against a closed one is the case worth keeping rather than dropping:
5748        // a defect that was fixed, closed, and has come back is exactly the
5749        // finding a maintainer wants, and "#N covers it and is closed" wrote it
5750        // nowhere. An exact match is still checked against every state, since
5751        // that is a regression of one specific issue rather than a resemblance
5752        // to one, and a tracker still searches closed issues because a
5753        // checklist item matching one is a done item that wants a link.
5754        let state = if include_closed { "all" } else { "open" };
5755        let text = self.gh(&[
5756            "issue",
5757            "list",
5758            "--state",
5759            state,
5760            "--limit",
5761            "100",
5762            "--search",
5763            query.trim(),
5764            "--json",
5765            "number,title,url,body,state,stateReason",
5766        ])?;
5767        let rows: Vec<Row> = serde_json::from_str(text.trim())
5768            .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
5769
5770        Ok(rows
5771            .into_iter()
5772            .filter(|row| Some(row.number) != apart_from)
5773            .find(|row| {
5774                row.title.trim().eq_ignore_ascii_case(title.trim())
5775                    || textsim::covers(title, body, &row.title, &row.body)
5776            })
5777            .map(|row| ExistingIssue {
5778                number: row.number,
5779                url: row.url,
5780                title: row.title,
5781                open: row.state.eq_ignore_ascii_case("open"),
5782                body: row.body,
5783            }))
5784    }
5785
5786    /// Avoid filing a duplicate when a follow-up already exists.
5787    pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
5788        #[derive(Deserialize)]
5789        struct Row {
5790            title: String,
5791            url: String,
5792        }
5793        let needle = title.trim().to_lowercase();
5794        if needle.is_empty() {
5795            return None;
5796        }
5797        // Quotes and newlines would be read as search syntax rather than text.
5798        let query: String = title
5799            .chars()
5800            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5801            .take(120)
5802            .collect();
5803        let text = self.gh_try(&[
5804            "issue",
5805            "list",
5806            "--state",
5807            "all",
5808            "--limit",
5809            "100",
5810            "--search",
5811            query.trim(),
5812            "--json",
5813            "number,title,url",
5814        ]);
5815        serde_json::from_str::<Vec<Row>>(text.trim())
5816            .ok()?
5817            .into_iter()
5818            .find(|row| row.title.trim().to_lowercase() == needle)
5819            .map(|row| row.url)
5820    }
5821
5822    /// Squash merge, tolerating cleanup failures after a successful merge.
5823    ///
5824    /// Take a pull request out of draft, once the review has converged.
5825    ///
5826    /// Best effort for the remaining workflow. A failure does not discard the
5827    /// review or stop later independent work, but the final write summary
5828    /// reports it and the command returns non-zero.
5829    pub fn mark_ready(&self, number: i64) -> bool {
5830        match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
5831            Ok(_) => true,
5832            Err(e) => {
5833                logdim!(
5834                    "PR #{number} is approved but could not be taken out of draft: {}",
5835                    e.last_line()
5836                );
5837                false
5838            }
5839        }
5840    }
5841
5842    /// The worktree that has `branch` checked out, if one does.
5843    fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
5844        let listed = self
5845            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
5846            .ok()?;
5847        let wanted = format!("branch refs/heads/{branch}");
5848        let mut current: Option<PathBuf> = None;
5849        for line in listed.lines() {
5850            if let Some(path) = line.strip_prefix("worktree ") {
5851                current = Some(PathBuf::from(path));
5852            } else if line == wanted {
5853                return current;
5854            }
5855        }
5856        None
5857    }
5858
5859    /// Finish the local cleanup a merge could not.
5860    ///
5861    /// `gh pr merge --delete-branch` gives up on the local branch whenever any
5862    /// worktree has it checked out, which is the ordinary state rather than an
5863    /// odd one: `spar run` opens the pull request from
5864    /// `.spar-worktrees/issue-N` and leaves the checkout there, and the merge
5865    /// usually happens in a later `spar resume` working somewhere else. The
5866    /// next run's sweep tidied it, so nothing was ever lost, but the run that
5867    /// did the merge reported a failure about a branch nobody will touch again
5868    /// and left a checkout for the next run to explain.
5869    ///
5870    /// Held to the same rules the sweep uses, because they are the rules that
5871    /// decide whether anything would be lost: a branch spar recorded creating,
5872    /// a worktree of spar's own with no recoverable work in it, and a tip some
5873    /// surviving ref preserves. Anything else is left exactly where it is.
5874    fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
5875        let branch = self.pr_view(number).ok()?.head_ref_name;
5876        if branch.is_empty() || !self.known_branches().contains_key(&branch) {
5877            return None;
5878        }
5879        let mut released = None;
5880        if let Some(path) = self.worktree_holding(&branch) {
5881            if !path.starts_with(self.root.join(WORKTREE_DIR)) {
5882                return None;
5883            }
5884            if !matches!(
5885                self.has_recoverable_work(&path, Disposable::BuildOutput),
5886                Ok(false)
5887            ) {
5888                return None;
5889            }
5890            match self.remove_worktree_at(&path) {
5891                Ok(true) => released = Some(path),
5892                _ => return None,
5893            }
5894        }
5895        match self.delete_branch_if_safe(&branch) {
5896            Ok(true) => {
5897                self.forget_branch(&branch);
5898                Some(match released {
5899                    Some(path) => format!(
5900                        " The worktree at {} was released and branch {branch} deleted.",
5901                        path.display()
5902                    ),
5903                    None => format!(" Branch {branch} was deleted."),
5904                })
5905            }
5906            _ => released.map(|path| {
5907                format!(
5908                    " The worktree at {} was released, but branch {branch} was kept.",
5909                    path.display()
5910                )
5911            }),
5912        }
5913    }
5914
5915    /// `gh pr merge --delete-branch` exits non-zero when it cannot delete the
5916    /// local branch, which happens *after* the merge has already landed.
5917    /// Treating that as a failure reports work as lost when it is not.
5918    pub fn merge_pr(&self, number: i64) -> Result<()> {
5919        let n = number.to_string();
5920        let merged = match self.gh(&merge_pr_args(&n, None, true)) {
5921            Ok(_) => Ok(()),
5922            Err(e) => {
5923                if self.pr_state(number) == "MERGED" {
5924                    let finished = self
5925                        .finish_merged_branch_cleanup(number)
5926                        .unwrap_or_default();
5927                    logdim!(
5928                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5929                        e.last_line()
5930                    );
5931                    Ok(())
5932                } else {
5933                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5934                }
5935            }
5936        };
5937        self.record_write(merged)
5938    }
5939
5940    /// Squash merge only if the pull request still exposes the reviewed head.
5941    pub fn merge_pr_at_head(
5942        &self,
5943        number: i64,
5944        expected_head: &str,
5945        delete_branch: bool,
5946    ) -> Result<()> {
5947        let n = number.to_string();
5948        let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
5949            Ok(_) => Ok(()),
5950            Err(e) => {
5951                if self.pr_state(number) == "MERGED" {
5952                    let finished = self
5953                        .finish_merged_branch_cleanup(number)
5954                        .unwrap_or_default();
5955                    logdim!(
5956                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5957                        e.last_line()
5958                    );
5959                    Ok(())
5960                } else {
5961                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5962                }
5963            }
5964        };
5965        self.record_write(merged)
5966    }
5967
5968    // -- follow-ups -------------------------------------------------------
5969
5970    /// The queue of follow-ups recorded locally rather than filed, which
5971    /// `spar followup` works.
5972    pub fn followups_path(&self) -> PathBuf {
5973        self.root.join(STATE_DIR).join("followups.md")
5974    }
5975
5976    /// Where `spar brainstorm` writes a session unless told otherwise.
5977    ///
5978    /// Under the state directory, so it is kept out of the repository's own
5979    /// `git status`; `--out` puts one somewhere a person would commit.
5980    pub fn brainstorms_dir(&self) -> PathBuf {
5981        self.root.join(STATE_DIR).join("brainstorms")
5982    }
5983
5984    /// What `spar followup` already dealt with, kept beside the queue.
5985    ///
5986    /// Two jobs. It is what stops `append_local_followup` re-recording a
5987    /// follow-up whose entry has since left the queue, which would otherwise
5988    /// turn the file into a ring buffer of things already filed. And it keeps
5989    /// the text of an entry a screening pass ruled stale, so a wrong verdict
5990    /// costs a re-read rather than the only copy of a real defect.
5991    pub fn worked_followups_path(&self) -> PathBuf {
5992        self.root.join(STATE_DIR).join("followups.done.md")
5993    }
5994
5995    /// What `spar checkin` has already answered on one pull request or issue.
5996    /// Where what a run spent is kept.
5997    pub fn spend_path(&self) -> PathBuf {
5998        self.root.join(STATE_DIR).join("state").join("spend.json")
5999    }
6000
6001    /// Append this run's calls to the record.
6002    ///
6003    /// Appended rather than replaced, so a resumed pull request adds to the
6004    /// count of what it has cost so far rather than starting it again, and so
6005    /// anybody charting this has a file to chart.
6006    pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
6007        if calls.is_empty() {
6008            return Ok(());
6009        }
6010        let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
6011            .ok()
6012            .and_then(|text| serde_json::from_str(&text).ok())
6013            .unwrap_or_default();
6014        all.extend(calls.iter().cloned());
6015        if let Some(parent) = self.spend_path().parent() {
6016            let _ = std::fs::create_dir_all(parent);
6017        }
6018        write_json_atomic(&self.spend_path(), &all)
6019    }
6020
6021    /// Who wrote the first draft of each issue, by number.
6022    ///
6023    /// Custody alternates within a pull request and the record of it lives in
6024    /// that pull request's state. This is the other question, which nothing
6025    /// answered: over a queue, which model authored what.
6026    pub fn implementors_path(&self) -> PathBuf {
6027        self.root
6028            .join(STATE_DIR)
6029            .join("state")
6030            .join("implementors.json")
6031    }
6032
6033    pub fn read_implementors(&self) -> BTreeMap<i64, String> {
6034        std::fs::read_to_string(self.implementors_path())
6035            .ok()
6036            .and_then(|text| serde_json::from_str(&text).ok())
6037            .unwrap_or_default()
6038    }
6039
6040    /// Best effort: this is a record for a person, not something the loop reads
6041    /// back, so a failure to write it is not a reason to stop.
6042    pub fn remember_implementor(&self, issue: i64, agent: &str) {
6043        let mut known = self.read_implementors();
6044        known.insert(issue, agent.to_string());
6045        if let Some(parent) = self.implementors_path().parent() {
6046            let _ = std::fs::create_dir_all(parent);
6047        }
6048        if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
6049            logdim!("could not record who implemented #{issue}: {e}");
6050        }
6051    }
6052
6053    /// Where the issues triage disagreed about are remembered.
6054    ///
6055    /// One file rather than one per issue: it is read once per run, before any
6056    /// triage call, to decide what not to ask about again.
6057    pub fn contested_path(&self) -> PathBuf {
6058        self.root
6059            .join(STATE_DIR)
6060            .join("state")
6061            .join("contested.json")
6062    }
6063
6064    /// The issues a person has been asked to decide, by number.
6065    pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
6066        std::fs::read_to_string(self.contested_path())
6067            .ok()
6068            .and_then(|text| serde_json::from_str(&text).ok())
6069            .unwrap_or_default()
6070    }
6071
6072    /// Remember an issue triage could not agree on.
6073    ///
6074    /// Best effort: a run that cannot write this asks again next time, which is
6075    /// what it did before this existed.
6076    pub fn remember_contested(&self, items: &[ContestedItem]) {
6077        if items.is_empty() {
6078            return;
6079        }
6080        let mut known = self.read_contested();
6081        for item in items {
6082            known.insert(
6083                item.issue,
6084                Contested {
6085                    title: item.title.clone(),
6086                    positions: item.positions.clone(),
6087                    reasons: item.reasons.clone(),
6088                },
6089            );
6090        }
6091        if let Some(parent) = self.contested_path().parent() {
6092            let _ = std::fs::create_dir_all(parent);
6093        }
6094        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
6095            logdim!("could not record the contested issues: {e}");
6096        }
6097    }
6098
6099    /// Ask about them again, because a person has acted on them.
6100    pub fn forget_contested(&self, numbers: &[i64]) {
6101        let mut known = self.read_contested();
6102        let before = known.len();
6103        known.retain(|number, _| !numbers.contains(number));
6104        if known.len() == before {
6105            return;
6106        }
6107        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
6108            logdim!("could not update the contested issues: {e}");
6109        }
6110    }
6111
6112    pub fn checkin_state_path(&self, number: i64) -> PathBuf {
6113        self.root
6114            .join(STATE_DIR)
6115            .join("state")
6116            .join(format!("checkin-{number}.json"))
6117    }
6118
6119    /// Where `spar answer` records which mentions on an issue it judged.
6120    pub fn answer_state_path(&self, number: i64) -> PathBuf {
6121        self.root
6122            .join(STATE_DIR)
6123            .join("state")
6124            .join(format!("answer-{number}.json"))
6125    }
6126
6127    /// Where `spar answer` keeps what it left for a person to answer. Notes,
6128    /// never posted.
6129    pub fn answer_notes_path(&self, number: i64) -> PathBuf {
6130        self.root
6131            .join(STATE_DIR)
6132            .join("answers")
6133            .join(format!("issue-{number}.md"))
6134    }
6135
6136    /// Where `spar review` records what it reviewed, and a second look reads it.
6137    pub fn review_record_path(&self, number: i64) -> PathBuf {
6138        self.root
6139            .join(STATE_DIR)
6140            .join("state")
6141            .join(format!("review-{number}.json"))
6142    }
6143
6144    /// None when there is no record, or when it cannot be read. A record spar
6145    /// cannot read is one it reviews afresh over, and the log says so.
6146    pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
6147        let path = self.review_record_path(number);
6148        let text = std::fs::read_to_string(&path).ok()?;
6149        match serde_json::from_str(&text) {
6150            Ok(record) => Some(record),
6151            Err(e) => {
6152                logdim!("could not read {}: {e}", path.display());
6153                None
6154            }
6155        }
6156    }
6157
6158    pub fn write_review_record(
6159        &self,
6160        number: i64,
6161        record: &crate::model::ReviewRecord,
6162    ) -> Result<()> {
6163        write_json_atomic(&self.review_record_path(number), record)
6164    }
6165
6166    /// Append a follow-up to a local note instead of the tracker.
6167    ///
6168    /// Deduplicated on the title, matching the issue path. The body arrives
6169    /// with its provenance already stamped by the caller, so nothing is added
6170    /// here.
6171    ///
6172    /// A write that did not happen is reported as such rather than as a
6173    /// duplicate: the caller settles the point on the strength of this answer,
6174    /// and settling it on a failed write is how a real defect is lost.
6175    ///
6176    /// Both files are checked, because `spar followup` removes an entry from
6177    /// the queue once it has filed it. Checking only the queue would let the
6178    /// next run that rediscovers the same defect append it again, on top of the
6179    /// issue that now exists for it.
6180    pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
6181        let path = self.followups_path();
6182        let heading = format!("## {}", title.trim());
6183        for seen in [&path, &self.worked_followups_path()] {
6184            if let Ok(existing) = std::fs::read_to_string(seen) {
6185                if has_heading(&existing, title) {
6186                    logdim!("follow-up already noted: {title}");
6187                    return Followup::Covered(format!("note: {}", title.trim()));
6188                }
6189            }
6190        }
6191        if let Some(parent) = path.parent() {
6192            let _ = std::fs::create_dir_all(parent);
6193        }
6194        use std::io::Write;
6195        // The caller already stamped the provenance into the body. Adding
6196        // "From #N." here as well printed it twice, in two different wordings.
6197        //
6198        // The marker above the heading is what makes the entry boundary
6199        // unambiguous to the parser, since the body's own sections are written
6200        // at the same heading level as the title.
6201        let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
6202        match std::fs::OpenOptions::new()
6203            .create(true)
6204            .append(true)
6205            .open(&path)
6206        {
6207            Ok(mut file) => match file.write_all(entry.as_bytes()) {
6208                Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
6209                Err(e) => {
6210                    logdim!("could not write {}: {e}", path.display());
6211                    Followup::Failed
6212                }
6213            },
6214            Err(e) => {
6215                logdim!("could not write {}: {e}", path.display());
6216                Followup::Failed
6217            }
6218        }
6219    }
6220
6221    /// Record what `spar followup` did with an entry, and why.
6222    ///
6223    /// Best effort: an archive that could not be written is not a reason to
6224    /// stop, since the entry has already been filed or ruled on.
6225    pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
6226        let path = self.worked_followups_path();
6227        if let Some(parent) = path.parent() {
6228            let _ = std::fs::create_dir_all(parent);
6229        }
6230        use std::io::Write;
6231        let entry = format!(
6232            "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
6233            title.trim(),
6234            body.trim()
6235        );
6236        if let Ok(mut file) = std::fs::OpenOptions::new()
6237            .create(true)
6238            .append(true)
6239            .open(&path)
6240        {
6241            let _ = file.write_all(entry.as_bytes());
6242        }
6243    }
6244
6245    // -- resumable state --------------------------------------------------
6246    //
6247    // Custody cannot be read from GitHub authorship: every agent commits and
6248    // comments as the same git identity, so `author` is always the human who
6249    // ran spar. State is kept on disk by default and can additionally travel in
6250    // a PR comment, which is what lets a run be resumed from another machine.
6251
6252    /// Where a comment spar produced but did not post is kept.
6253    pub fn pending_comment_path(&self, number: i64) -> PathBuf {
6254        self.root
6255            .join(STATE_DIR)
6256            .join("reviews")
6257            .join(format!("pr-{number}.md"))
6258    }
6259
6260    /// Keep a comment spar decided not to post.
6261    ///
6262    /// A dry run that prints and forgets means agreeing with what you read
6263    /// costs a second full review. Saving it makes the whole point of reading
6264    /// it first: look, edit if you like, then post what you already paid for.
6265    pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
6266        let path = self.pending_comment_path(number);
6267        if let Some(parent) = path.parent() {
6268            std::fs::create_dir_all(parent)
6269                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6270        }
6271        std::fs::write(&path, text)
6272            .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6273        Ok(path)
6274    }
6275
6276    pub fn read_pending_comment(&self, number: i64) -> Option<String> {
6277        std::fs::read_to_string(self.pending_comment_path(number)).ok()
6278    }
6279
6280    pub fn state_path(&self, number: i64) -> PathBuf {
6281        self.root
6282            .join(STATE_DIR)
6283            .join("state")
6284            .join(format!("pr-{number}.json"))
6285    }
6286
6287    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
6288        let path = self.state_path(number);
6289        let text = std::fs::read_to_string(&path).ok()?;
6290        match serde_json::from_str(&text) {
6291            Ok(state) => Some(state),
6292            Err(_) => {
6293                logdim!("could not read {}, starting fresh", path.display());
6294                None
6295            }
6296        }
6297    }
6298
6299    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
6300        if let Some(local) = self.read_local_state(pr.number) {
6301            return Some(local);
6302        }
6303        if self.state_store.writes_pr() {
6304            return self.read_pr_state(pr.number);
6305        }
6306        None
6307    }
6308
6309    pub(crate) fn read_state_for_head(
6310        &self,
6311        pr: &PrView,
6312        actual_head: &str,
6313    ) -> Option<PersistedState> {
6314        let local = self
6315            .state_store
6316            .writes_local()
6317            .then(|| self.read_local_state(pr.number))
6318            .flatten();
6319        let remote = self
6320            .state_store
6321            .writes_pr()
6322            .then(|| self.read_pr_state(pr.number))
6323            .flatten();
6324        let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
6325        if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
6326            self.remember_checkpoint(pr.number, checkpoint);
6327        }
6328        choose_state_for_head(candidates, actual_head)
6329    }
6330
6331    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
6332        self.try_read_pr_state(number).ok().flatten()
6333    }
6334
6335    fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
6336        for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
6337            if let Some(state) = parse_state_comment(&body) {
6338                return Ok(Some(state));
6339            }
6340        }
6341        Ok(None)
6342    }
6343
6344    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6345        let remote_state = if self.state_store.writes_pr() {
6346            self.try_read_pr_state(number)
6347        } else {
6348            Ok(None)
6349        };
6350        self.write_state_after_remote_read(number, state, remote_state)
6351    }
6352
6353    fn write_state_after_remote_read(
6354        &self,
6355        number: i64,
6356        state: &PersistedState,
6357        remote_state: Result<Option<PersistedState>>,
6358    ) -> Result<()> {
6359        let remote_checkpoint = if self.state_store.writes_pr() {
6360            self.record_failed_write(remote_state)?
6361                .map(|saved| saved.checkpoint)
6362                .unwrap_or_default()
6363        } else {
6364            0
6365        };
6366        let local_checkpoint = self
6367            .state_store
6368            .writes_local()
6369            .then(|| self.read_local_state(number))
6370            .flatten()
6371            .map(|saved| saved.checkpoint)
6372            .unwrap_or_default();
6373        let mut stamped = state.clone();
6374        stamped.checkpoint = state
6375            .checkpoint
6376            .max(local_checkpoint)
6377            .max(remote_checkpoint)
6378            .max(self.remembered_checkpoint(number))
6379            .saturating_add(1);
6380        self.remember_checkpoint(number, stamped.checkpoint);
6381        if self.state_store.writes_local() {
6382            write_json_atomic(&self.state_path(number), &stamped)?;
6383        }
6384        if self.state_store.writes_pr() {
6385            self.write_pr_state(number, &stamped)?;
6386        }
6387        Ok(())
6388    }
6389
6390    fn remembered_checkpoint(&self, number: i64) -> u64 {
6391        self.checkpoints
6392            .lock()
6393            .unwrap_or_else(std::sync::PoisonError::into_inner)
6394            .get(&number)
6395            .copied()
6396            .unwrap_or_default()
6397    }
6398
6399    fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
6400        let mut checkpoints = self
6401            .checkpoints
6402            .lock()
6403            .unwrap_or_else(std::sync::PoisonError::into_inner);
6404        let saved = checkpoints.entry(number).or_default();
6405        *saved = (*saved).max(checkpoint);
6406    }
6407
6408    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6409        // Not run through clean(): this is structured data, and scrubbing would
6410        // corrupt refutation text stored in the ledger. It sits inside an
6411        // unclosed HTML comment so GitHub renders it as nothing.
6412        let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
6413        let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
6414        let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
6415        if let Some(id) = comment_id {
6416            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6417            let field = format!("body={body}");
6418            let written = self
6419                .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
6420                .map(|_| ());
6421            return self.record_write(written);
6422        }
6423        let written = self
6424            .gh(&["pr", "comment", &number.to_string(), "--body", &body])
6425            .map(|_| ());
6426        self.record_write(written)
6427    }
6428
6429    /// Top level comments. Works for issues and pull requests alike, because
6430    /// GitHub serves both from the issues endpoint.
6431    ///
6432    /// Nothing when they cannot be read, which suits a reader that is going to
6433    /// go on regardless. A caller deciding whether it has already written here
6434    /// wants `try_issue_comments`, since for that one no comments and no answer
6435    /// are opposite answers.
6436    pub fn issue_comments(&self, number: i64) -> Vec<Value> {
6437        self.try_issue_comments(number).unwrap_or_default()
6438    }
6439
6440    pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
6441        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
6442        try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
6443    }
6444
6445    /// An issue as the REST API returns it, which is the one read that says
6446    /// who filed it in the shape a comment has: `user` with its `type`, and
6447    /// `author_association`, neither of which `gh issue view` gives.
6448    pub fn issue_row(&self, number: i64) -> Result<Value> {
6449        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
6450        let text = self
6451            .gh(&["api", &path])
6452            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
6453        serde_json::from_str(&text)
6454            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
6455    }
6456
6457    /// Open issues in this repository that mention `login`, lowest numbered
6458    /// first.
6459    pub fn open_issues_mentioning(&self, login: &str, limit: usize) -> Result<Vec<i64>> {
6460        #[derive(Deserialize)]
6461        struct Row {
6462            number: i64,
6463        }
6464        // `{owner}/{repo}` is filled in by `gh api` alone.
6465        let name = self
6466            .repo_name()
6467            .ok_or_else(|| spar_err!("could not find out which repository this is"))?;
6468        let text = self.gh(&[
6469            "search",
6470            "issues",
6471            "--repo",
6472            &name,
6473            "--mentions",
6474            login,
6475            "--state",
6476            "open",
6477            "--limit",
6478            &FETCH_CEILING.to_string(),
6479            "--json",
6480            "number",
6481        ])?;
6482        let rows: Vec<Row> = serde_json::from_str(text.trim())
6483            .map_err(|e| spar_err!("unexpected shape for the issues mentioning @{login}: {e}"))?;
6484        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
6485        numbers.sort_unstable();
6486        numbers.truncate(limit);
6487        Ok(numbers)
6488    }
6489
6490    /// spar's own state comments on a pull request, newest last.
6491    ///
6492    /// Three tests, because the answer decides what gets patched over and what
6493    /// gets deleted. A comment matching the marker loosely, which is what
6494    /// somebody asking "why is there a `<!-- spar:state` block on this PR?"
6495    /// writes, is named in the log and left exactly where it is.
6496    fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
6497        let viewer = self.viewer_login()?.to_string();
6498        let mut mine = Vec::new();
6499        let mut theirs = Vec::new();
6500        for comment in self.try_issue_comments(number)? {
6501            let Some(body) = comment.get("body").and_then(Value::as_str) else {
6502                continue;
6503            };
6504            if !body.contains("spar:state") {
6505                continue;
6506            }
6507            let Some(id) = comment.get("id").and_then(Value::as_i64) else {
6508                continue;
6509            };
6510            let author = comment
6511                .get("user")
6512                .and_then(|u| u.get("login"))
6513                .and_then(Value::as_str)
6514                .unwrap_or_default();
6515            if is_state_comment(body, author, &viewer) {
6516                mine.push((id, body.to_string()));
6517            } else {
6518                theirs.push(format!("@{author}"));
6519            }
6520        }
6521        if !theirs.is_empty() {
6522            logdim!(
6523                "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
6524                 own state block is patched or deleted.",
6525                theirs.len(),
6526                theirs.join(", ")
6527            );
6528        }
6529        Ok(mine)
6530    }
6531
6532    fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
6533        Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
6534    }
6535
6536    /// Drop state once the PR is finished and there is nothing to resume.
6537    pub fn clear_state(&self, number: i64) {
6538        let path = self.state_path(number);
6539        let _ = std::fs::remove_file(&path);
6540        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
6541    }
6542
6543    // -- housekeeping -----------------------------------------------------
6544
6545    /// Remove state files whose PR is merged or closed: the custody loop's
6546    /// state, the review record, and the check-in watermark alike. Grouped by
6547    /// number so each finished pull request costs one `gh` read however many
6548    /// files it left.
6549    pub fn prune_state(&self) -> Vec<String> {
6550        let base = self.root.join(STATE_DIR).join("state");
6551        let Ok(entries) = std::fs::read_dir(&base) else {
6552            return Vec::new();
6553        };
6554        let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
6555        for name in entries
6556            .flatten()
6557            .filter_map(|e| e.file_name().to_str().map(str::to_string))
6558        {
6559            if let Some(number) = state_file_number(&name) {
6560                by_number.entry(number).or_default().push(name);
6561            }
6562        }
6563
6564        let mut removed = Vec::new();
6565        for (number, mut names) in by_number {
6566            if !is_finished(&self.pr_state(number)) {
6567                continue;
6568            }
6569            names.sort();
6570            for name in names {
6571                let _ = std::fs::remove_file(base.join(&name));
6572                removed.push(format!("state {name}"));
6573            }
6574        }
6575        removed
6576    }
6577
6578    /// Delete state comments from PRs that are finished.
6579    ///
6580    /// Open PRs are left alone: their state may still be live.
6581    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
6582        #[derive(Deserialize)]
6583        struct Row {
6584            number: i64,
6585        }
6586        let numbers = match numbers {
6587            Some(numbers) => numbers,
6588            None => {
6589                let listed: Result<Vec<i64>> = (|| {
6590                    let text = self.gh(&[
6591                        "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
6592                    ])?;
6593                    let rows = serde_json::from_str::<Vec<Row>>(text.trim())
6594                        .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
6595                    Ok(rows.into_iter().map(|row| row.number).collect())
6596                })();
6597                match self.record_failed_write(listed) {
6598                    Ok(numbers) => numbers,
6599                    Err(e) => {
6600                        logdim!("could not inspect pull requests for state cleanup: {e}");
6601                        return Vec::new();
6602                    }
6603                }
6604            }
6605        };
6606
6607        let mut removed = Vec::new();
6608        for number in numbers {
6609            let state = match self.record_failed_write(self.try_pr_state(number)) {
6610                Ok(state) => state,
6611                Err(e) => {
6612                    logdim!("could not inspect PR #{number} for state cleanup: {e}");
6613                    continue;
6614                }
6615            };
6616            if !is_finished(&state) {
6617                continue;
6618            }
6619            let comments = match self.record_failed_write(self.try_state_comments(number)) {
6620                Ok(comments) => comments,
6621                Err(e) => {
6622                    logdim!("could not inspect state comments on PR #{number}: {e}");
6623                    continue;
6624                }
6625            };
6626            for (id, _) in comments {
6627                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6628                let deleted = self
6629                    .gh(&["api", "-X", "DELETE", &path, "--silent"])
6630                    .map(|_| ());
6631                match self.record_write(deleted) {
6632                    Ok(()) => removed.push(format!("state comment on PR #{number}")),
6633                    Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
6634                }
6635            }
6636        }
6637        removed
6638    }
6639
6640    /// Drop worktrees whose PR is finished, then the branches they left behind.
6641    ///
6642    /// With auto_merge off, which is the default, a run ends at "approved", so
6643    /// nothing would ever clean these up on its own and they accumulate one per
6644    /// run. A stranded worktree also holds its branch checked out, which makes
6645    /// a later `gh pr merge --delete-branch` fail to clean up.
6646    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
6647        let base = self.root.join(WORKTREE_DIR);
6648        let mut removed = Vec::new();
6649        let known = self.known_branches();
6650
6651        if let Ok(entries) = std::fs::read_dir(&base) {
6652            let mut names: Vec<String> = entries
6653                .flatten()
6654                .filter(|e| e.path().is_dir())
6655                .filter_map(|e| e.file_name().to_str().map(str::to_string))
6656                .collect();
6657            names.sort();
6658
6659            for name in names {
6660                // A read checkout is released by the run that made it, so one
6661                // still here was either kept for recovery or left by a run
6662                // that never got to release it.
6663                if let Some(owner) = read_checkout_owner(&name) {
6664                    let path = base.join(&name);
6665                    if force_all {
6666                        self.remove_worktree_at_force(&path);
6667                    } else if process_is_running(owner) {
6668                        continue;
6669                    } else if has_recovery_marker(&path) {
6670                        logdim!("kept {} for recovery", path.display());
6671                        continue;
6672                    } else {
6673                        self.discard_read_checkout(&path);
6674                    }
6675                    if !path.exists() {
6676                        removed.push(name);
6677                    }
6678                    continue;
6679                }
6680                // A review worktree is detached and owns no branch, so it is
6681                // tied to the pull request only by its directory name.
6682                if let Some(rest) = name.strip_prefix("review-") {
6683                    let number: i64 = rest.parse().unwrap_or(-1);
6684                    if !(force_all || is_finished(&self.pr_state(number))) {
6685                        continue;
6686                    }
6687                    let path = base.join(&name);
6688                    if force_all {
6689                        let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
6690                            if !belongs {
6691                                return Ok(false);
6692                            }
6693                            let local_ref = review_ref(number);
6694                            if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
6695                                return Ok(false);
6696                            }
6697                            let head = self.head_oid_checked(&path)?;
6698                            let recorded = self
6699                                .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
6700                                .trim()
6701                                .to_string();
6702                            Ok(head == recorded)
6703                        });
6704                        match owned {
6705                            Ok(true) => {}
6706                            Ok(false) => {
6707                                logdim!(
6708                                    "kept {} because no matching SPAR review reference proves \
6709                                     ownership",
6710                                    path.display()
6711                                );
6712                                continue;
6713                            }
6714                            Err(e) => {
6715                                logdim!(
6716                                    "kept {} because review ownership could not be verified: {}",
6717                                    path.display(),
6718                                    e.last_line()
6719                                );
6720                                continue;
6721                            }
6722                        }
6723                    } else {
6724                        if let Err(e) = self.refuse_review_worktree_changes(number) {
6725                            logdim!(
6726                                "kept {} because its review state could not be verified as \
6727                                 disposable: {}",
6728                                path.display(),
6729                                e.last_line()
6730                            );
6731                            continue;
6732                        }
6733                    }
6734                    if force_all {
6735                        if self.remove_worktree_at_force(&path) {
6736                            self.git_try(&["update-ref", "-d", &review_ref(number)]);
6737                        }
6738                    } else {
6739                        self.release_review_worktree(number);
6740                    }
6741                    if !path.exists() {
6742                        removed.push(name);
6743                    }
6744                    continue;
6745                }
6746                let branch = format!("{}{name}", self.branch_prefix);
6747                if !(force_all || self.worktree_is_done(&branch)) {
6748                    continue;
6749                }
6750                if !known.contains_key(&branch) {
6751                    logdim!("kept {branch} because it has no branch record");
6752                    continue;
6753                }
6754                let path = base.join(&name);
6755                if !force_all {
6756                    match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6757                        Ok(true) => {
6758                            logdim!(
6759                                "kept {} because it contains uncommitted changes or ignored files",
6760                                path.display()
6761                            );
6762                            continue;
6763                        }
6764                        Err(e) => {
6765                            logdim!(
6766                                "kept {} because its Git state could not be checked: {}",
6767                                path.display(),
6768                                e.last_line()
6769                            );
6770                            continue;
6771                        }
6772                        Ok(false) => {}
6773                    }
6774                    match self.branch_deletion_is_safe(&branch) {
6775                        Ok(true) => {}
6776                        Ok(false) => {
6777                            logdim!(
6778                                "kept {branch} because no surviving ref preserves its tip or \
6779                                 reflog-only commits"
6780                            );
6781                            continue;
6782                        }
6783                        Err(e) => {
6784                            logdim!(
6785                                "kept {branch} because preservation could not be verified: {}",
6786                                e.last_line()
6787                            );
6788                            continue;
6789                        }
6790                    }
6791                }
6792                let removed_worktree = if force_all {
6793                    self.remove_worktree_at_force(&path)
6794                } else {
6795                    match self.remove_worktree_at(&path) {
6796                        Ok(removed) => removed,
6797                        Err(error) => {
6798                            logdim!(
6799                                "kept {branch} and {} because removal did not reach a confirmed \
6800                                 quiet point: {}",
6801                                path.display(),
6802                                error.last_line()
6803                            );
6804                            false
6805                        }
6806                    }
6807                };
6808                if !removed_worktree {
6809                    continue;
6810                }
6811                if force_all {
6812                    self.git_try(&["branch", "-D", &branch]);
6813                    self.forget_branch(&branch);
6814                } else {
6815                    match self.delete_branch_if_safe(&branch) {
6816                        Ok(true) => self.forget_branch(&branch),
6817                        Ok(false) => logdim!(
6818                            "kept {branch} because its tip or reflog changed before deletion"
6819                        ),
6820                        Err(error) => logdim!(
6821                            "kept {branch} because deletion safety could not be rechecked: {}",
6822                            error.last_line()
6823                        ),
6824                    }
6825                }
6826                removed.push(name);
6827            }
6828        }
6829        removed.extend(self.prune_branches(force_all));
6830        removed
6831    }
6832
6833    /// Delete leftover branches spar created whose worktree is already gone.
6834    ///
6835    /// Deletion is driven by the ledger of branches spar actually created, not
6836    /// by a name pattern. Names default to `issue-N`, which is exactly what a
6837    /// person would call a branch themselves, so a name alone can never
6838    /// establish ownership. This is the data loss guard.
6839    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
6840        let known = self.known_branches();
6841        let branches: Vec<String> = known.keys().cloned().collect();
6842        if branches.is_empty() {
6843            return Vec::new();
6844        }
6845
6846        let checked_out: Vec<String> = self
6847            .git_try(&["worktree", "list", "--porcelain"])
6848            .lines()
6849            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
6850            .collect();
6851
6852        // %(refname:short) is ambiguous when a tag shares the branch name (it
6853        // yields "heads/..."), so take the full ref and strip it here.
6854        let existing: Vec<String> = self
6855            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
6856            .lines()
6857            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
6858            .collect();
6859
6860        let mut removed = Vec::new();
6861        for branch in branches {
6862            if !existing.contains(&branch) {
6863                self.forget_branch(&branch); // already gone, drop the record
6864                continue;
6865            }
6866            if checked_out.contains(&branch) {
6867                continue;
6868            }
6869            if !(force_all || self.worktree_is_done(&branch)) {
6870                continue;
6871            }
6872            if !force_all {
6873                let Some(_record) = known.get(&branch) else {
6874                    continue;
6875                };
6876                match self.branch_deletion_is_safe(&branch) {
6877                    Ok(true) => {}
6878                    Ok(false) => {
6879                        logdim!(
6880                            "kept {branch} because no surviving ref preserves its tip or \
6881                             reflog-only commits"
6882                        );
6883                        continue;
6884                    }
6885                    Err(e) => {
6886                        logdim!(
6887                            "kept {branch} because preservation could not be verified: {}",
6888                            e.last_line()
6889                        );
6890                        continue;
6891                    }
6892                }
6893            }
6894            let deleted = if force_all {
6895                self.git(&["branch", "-D", &branch]).map(|_| true)
6896            } else {
6897                self.delete_branch_if_safe(&branch)
6898            };
6899            match deleted {
6900                Ok(true) => {
6901                    self.forget_branch(&branch);
6902                    removed.push(format!("branch {branch}"));
6903                }
6904                Ok(false) => {
6905                    logdim!("kept {branch} because its tip or reflog changed before deletion");
6906                }
6907                Err(e) => {
6908                    // A branch that silently survives pruning looks like a spar
6909                    // bug, so the name and git's own reason have to be said.
6910                    logdim!("could not delete {branch}: {}", e.last_line());
6911                }
6912            }
6913        }
6914        removed
6915    }
6916
6917    /// True when the PR behind this branch is merged or closed.
6918    fn worktree_is_done(&self, branch: &str) -> bool {
6919        #[derive(Deserialize)]
6920        struct Row {
6921            state: String,
6922        }
6923        let entry = branch
6924            .strip_prefix(self.branch_prefix.as_str())
6925            .unwrap_or(branch);
6926        if let Some(rest) = entry.strip_prefix("pr-") {
6927            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
6928        }
6929        // A split part is the same shape as an issue branch: one branch, whose
6930        // pull requests say whether it is finished. Without it here, a part
6931        // branch is one nothing but `clean --all` would ever remove.
6932        if entry.starts_with("issue-") || entry.starts_with("split-") {
6933            let text = self.gh_try(&[
6934                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
6935            ]);
6936            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
6937            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
6938        }
6939        false
6940    }
6941}
6942
6943// ---------------------------------------------------------------------------
6944// Free helpers
6945// ---------------------------------------------------------------------------
6946
6947/// Take a read-only probe again before treating its failure as evidence.
6948///
6949/// Every probe is a Git invocation under a thirty second cap plus a walk over a
6950/// tree an agent may still be building in. A probe that fails proves nothing
6951/// about the worktree, and there is a real difference between "the tree moved"
6952/// and "the question could not be asked just then", which the caller turns into
6953/// the same run-ending error.
6954pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
6955    match probe() {
6956        Ok(value) => Ok(value),
6957        Err(first) => {
6958            logdim!("retrying a worktree probe after: {}", first.last_line());
6959            probe()
6960        }
6961    }
6962}
6963
6964/// Read attribute files without asking Git to inspect working-tree content.
6965///
6966/// A newly written attribute can select a clean or smudge filter. It must be
6967/// detected before a post-call status, diff, or add command has a chance to run
6968/// that filter in the parent process.
6969pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
6970    let root = std::fs::canonicalize(cwd)
6971        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6972    let mut files = BTreeMap::new();
6973    let mut visited = BTreeSet::new();
6974    collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
6975    Ok(AttributeState { files })
6976}
6977
6978fn collect_attribute_files(
6979    root: &Path,
6980    repository: &Path,
6981    prefix: &Path,
6982    visited: &mut BTreeSet<PathBuf>,
6983    files: &mut BTreeMap<PathBuf, [u8; 32]>,
6984) -> Result<()> {
6985    let canonical = std::fs::canonicalize(repository)
6986        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6987    if !visited.insert(canonical) {
6988        bail!("submodule recursion revisited {}", repository.display());
6989    }
6990    let entries = index_entries(repository)?;
6991    let mut paths: BTreeSet<PathBuf> = entries
6992        .iter()
6993        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6994        .map(|entry| entry.path.clone())
6995        .collect();
6996    // Ignored on purpose. A `.gitattributes` under build output can only select
6997    // filters for paths that are themselves ignored, and no ignored path is
6998    // ever staged or compared as tracked content, so it cannot reach a managed
6999    // commit. Capturing it meant `cargo package` or `npm ci` writing one into
7000    // its own output ended a read-only call outright.
7001    paths.extend(untracked_attribute_paths(repository)?);
7002    for path in paths {
7003        let from_root = prefix.join(&path);
7004        let state = attribute_file_fingerprint(&root.join(&from_root))?;
7005        files.insert(from_root, state);
7006    }
7007    for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
7008        let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
7009            continue;
7010        };
7011        collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
7012    }
7013    Ok(())
7014}
7015
7016/// Leave a visible, untracked reason ordinary cleanup can detect on a later
7017/// run even when Git status normalizes the original working-file change away.
7018pub(crate) fn uncertain_worktree_change(
7019    cwd: &Path,
7020    message: impl Into<String>,
7021) -> crate::error::SparError {
7022    let message = message.into();
7023    let marker = write_recovery_marker(cwd, &message);
7024    let note = match marker {
7025        Ok(path) => format!(" Recovery marker: {}.", path.display()),
7026        Err(e) => format!(
7027            " A recovery marker could not be written: {}.",
7028            e.last_line()
7029        ),
7030    };
7031    crate::error::SparError::uncertain_write(format!("{message}{note}"))
7032}
7033
7034fn has_recovery_marker(cwd: &Path) -> bool {
7035    let Ok(entries) = std::fs::read_dir(cwd) else {
7036        return false;
7037    };
7038    entries.flatten().any(|entry| {
7039        entry
7040            .file_name()
7041            .to_str()
7042            .is_some_and(|name| name.starts_with(RECOVERY_MARKER_PREFIX))
7043    })
7044}
7045
7046/// The process a read checkout's directory name says made it.
7047fn read_checkout_owner(name: &str) -> Option<u32> {
7048    let rest = name.strip_prefix(READ_CHECKOUT_PREFIX)?;
7049    let (pid, serial) = rest.split_once('-')?;
7050    serial.parse::<usize>().ok()?;
7051    pid.parse().ok()
7052}
7053
7054/// Whether a process may still be running.
7055///
7056/// Anything short of proof that it is gone says yes. A wrong yes leaves a
7057/// checkout for `spar clean --all`; a wrong no deletes one under a running
7058/// call.
7059#[cfg(unix)]
7060fn process_is_running(pid: u32) -> bool {
7061    let Ok(pid) = libc::pid_t::try_from(pid) else {
7062        return true;
7063    };
7064    if pid <= 0 {
7065        return true;
7066    }
7067    if unsafe { libc::kill(pid, 0) } == 0 {
7068        return true;
7069    }
7070    std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
7071}
7072
7073#[cfg(not(unix))]
7074fn process_is_running(_pid: u32) -> bool {
7075    true
7076}
7077
7078fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
7079    use std::sync::atomic::{AtomicU32, Ordering};
7080    static NEXT: AtomicU32 = AtomicU32::new(0);
7081    for _ in 0..1000 {
7082        let serial = NEXT.fetch_add(1, Ordering::Relaxed);
7083        let path = cwd.join(format!(
7084            "{RECOVERY_MARKER_PREFIX}{}-{serial}",
7085            std::process::id()
7086        ));
7087        let mut options = OpenOptions::new();
7088        options.write(true).create_new(true);
7089        #[cfg(unix)]
7090        {
7091            use std::os::unix::fs::OpenOptionsExt;
7092            options.mode(0o600);
7093        }
7094        match options.open(&path) {
7095            Ok(mut file) => {
7096                file.write_all(detail.as_bytes())
7097                    .and_then(|_| file.write_all(b"\n"))
7098                    .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
7099                return Ok(path);
7100            }
7101            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
7102            Err(e) => {
7103                return Err(spar_err!(
7104                    "could not create a recovery marker in {}: {e}",
7105                    cwd.display()
7106                ))
7107            }
7108        }
7109    }
7110    bail!(
7111        "could not choose a free recovery marker name in {}",
7112        cwd.display()
7113    )
7114}
7115
7116/// Build a Git command that cannot launch automatic repository maintenance.
7117///
7118/// A fetch may otherwise prune missing linked worktree registrations. SPAR
7119/// must only remove registrations it has proven it owns.
7120fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
7121    let mut argv = vec![
7122        "git".to_string(),
7123        "-c".to_string(),
7124        "maintenance.auto=false".to_string(),
7125        "-c".to_string(),
7126        "gc.auto=0".to_string(),
7127    ];
7128    argv.extend(args.iter().map(|arg| (*arg).to_string()));
7129    argv
7130}
7131
7132fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
7133    let mut argv = git_without_maintenance_argv(&[]);
7134    argv.extend([
7135        "-c".to_string(),
7136        "core.fsmonitor=".to_string(),
7137        "-c".to_string(),
7138        "commit.gpgsign=false".to_string(),
7139        "-c".to_string(),
7140        "core.hooksPath=/dev/null".to_string(),
7141    ]);
7142    argv.extend(args.iter().map(|arg| (*arg).to_string()));
7143    argv
7144}
7145
7146/// Snapshot every untracked file, including ignored files, without changing
7147/// path bytes.
7148///
7149/// Without an exclude option, Git lists both ordinary and ignored untracked
7150/// entries. Metadata fingerprints make overwriting an existing path observable
7151/// without hashing a potentially multi-gigabyte build tree on every call.
7152pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
7153    let root = std::fs::canonicalize(cwd)
7154        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7155    let mut files = BTreeMap::new();
7156    let mut ignored = BTreeSet::new();
7157    let mut visited = BTreeSet::new();
7158    collect_untracked_files(
7159        &root,
7160        &root,
7161        Path::new(""),
7162        &mut visited,
7163        &mut files,
7164        &mut ignored,
7165    )?;
7166    Ok(IgnoredState { files, ignored })
7167}
7168
7169fn collect_untracked_files(
7170    root: &Path,
7171    repository: &Path,
7172    prefix: &Path,
7173    visited: &mut BTreeSet<PathBuf>,
7174    files: &mut BTreeMap<PathBuf, UntrackedFile>,
7175    ignored: &mut BTreeSet<PathBuf>,
7176) -> Result<()> {
7177    let canonical = std::fs::canonicalize(repository)
7178        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7179    if !visited.insert(canonical.clone()) {
7180        bail!("submodule recursion revisited {}", canonical.display());
7181    }
7182    // Classify ordinary paths in the listing itself. Listing every path here
7183    // and marking ignored paths in a second query misclassifies build output
7184    // deleted between queries as ordinary untracked work.
7185    let listed = run_git_bytes(
7186        repository,
7187        &["ls-files", "--others", "--exclude-standard", "-z"],
7188    )?;
7189    if !listed.is_empty() && !listed.ends_with(&[0]) {
7190        bail!(
7191            "git returned an unterminated untracked-file list for {}",
7192            repository.display()
7193        );
7194    }
7195
7196    for raw in listed
7197        .split(|byte| *byte == 0)
7198        .filter(|raw| !raw.is_empty())
7199    {
7200        let (relative, nested) = untracked_record(raw, "untracked")?;
7201        let from_root = prefix.join(&relative);
7202        if is_spar_own_path(&from_root) {
7203            continue;
7204        }
7205        let absolute = root.join(&from_root);
7206        let fingerprint = if nested {
7207            nested_repository_fingerprint(&absolute)?
7208        } else {
7209            ignored_file_fingerprint(&absolute)?
7210        };
7211        if files.insert(from_root.clone(), fingerprint).is_some() {
7212            bail!(
7213                "git returned the untracked path more than once: {:?}",
7214                from_root
7215            );
7216        }
7217    }
7218
7219    let ignored_listed = run_git_bytes(
7220        repository,
7221        &[
7222            "ls-files",
7223            "--others",
7224            "--ignored",
7225            "--exclude-standard",
7226            "-z",
7227        ],
7228    )?;
7229    if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
7230        bail!(
7231            "git returned an unterminated ignored-file list for {}",
7232            repository.display()
7233        );
7234    }
7235    // Two `ls-files` runs cannot be made to agree about a tree that is being
7236    // built in. A path named only by the second one was created between them,
7237    // which is a fact about the moment, not a corruption: record it and let the
7238    // comparison decide, rather than ending a run that had nothing wrong with
7239    // it.
7240    for raw in ignored_listed
7241        .split(|byte| *byte == 0)
7242        .filter(|raw| !raw.is_empty())
7243    {
7244        let (relative, nested) = untracked_record(raw, "ignored")?;
7245        let from_root = prefix.join(relative);
7246        if is_spar_own_path(&from_root) {
7247            continue;
7248        }
7249        if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
7250            let absolute = root.join(&from_root);
7251            let fingerprint = if nested {
7252                nested_repository_fingerprint(&absolute)?
7253            } else {
7254                ignored_file_fingerprint(&absolute)?
7255            };
7256            slot.insert(fingerprint);
7257        }
7258        if !ignored.insert(from_root.clone()) {
7259            bail!(
7260                "git returned the ignored path more than once: {:?}",
7261                from_root
7262            );
7263        }
7264    }
7265
7266    for link in gitlinks(repository)? {
7267        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7268            continue;
7269        };
7270        collect_untracked_files(
7271            root,
7272            &submodule,
7273            &prefix.join(&link.path),
7274            visited,
7275            files,
7276            ignored,
7277        )?;
7278    }
7279    Ok(())
7280}
7281
7282fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
7283    let argv = git_without_automation_argv(args);
7284    proc::run_bytes(
7285        &argv,
7286        &ExecOpts::new()
7287            .cwd(cwd)
7288            .timeout_secs(30)
7289            .stop_descendants(true),
7290    )
7291}
7292
7293fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
7294    let argv = git_without_automation_argv(args);
7295    proc::run(
7296        &argv,
7297        &ExecOpts::new()
7298            .cwd(cwd)
7299            .timeout_secs(30)
7300            .stop_descendants(true),
7301    )
7302}
7303
7304fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
7305    let path = path.to_str().ok_or_else(|| {
7306        spar_err!(
7307            "cannot verify filtered content for a non-UTF-8 path in {}",
7308            cwd.display()
7309        )
7310    })?;
7311    let path_arg = format!("--path={path}");
7312    let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
7313    Ok(Sha256::digest(bytes).into())
7314}
7315
7316fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
7317    let relative = path_from_git_bytes(raw)?;
7318    if relative.is_absolute()
7319        || relative.components().any(|component| {
7320            matches!(
7321                component,
7322                std::path::Component::ParentDir
7323                    | std::path::Component::RootDir
7324                    | std::path::Component::Prefix(_)
7325            )
7326        })
7327    {
7328        bail!("git returned an unsafe {kind} path: {:?}", relative);
7329    }
7330    Ok(relative)
7331}
7332
7333/// Split one `ls-files --others` record into its path and whether Git reported
7334/// a nested repository rather than a single file.
7335///
7336/// Git never lists the contents of a repository inside the working tree, so a
7337/// checkout parked there, such as another of SPAR's own worktrees, arrives as
7338/// one record for the directory itself ending in a separator. Git writes that
7339/// separator on every platform. Trimming it keeps the recorded path equal to
7340/// the same path seen any other way.
7341fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
7342    let nested = raw.last() == Some(&b'/');
7343    let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
7344    if trimmed.is_empty() {
7345        bail!("git returned an empty {kind} path");
7346    }
7347    Ok((safe_git_path(trimmed, kind)?, nested))
7348}
7349
7350fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
7351    let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
7352    if !listed.is_empty() && !listed.ends_with(&[0]) {
7353        bail!(
7354            "git returned an unterminated index listing for {}",
7355            cwd.display()
7356        );
7357    }
7358    let mut entries = Vec::new();
7359    for record in listed
7360        .split(|byte| *byte == 0)
7361        .filter(|record| !record.is_empty())
7362    {
7363        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7364            bail!(
7365                "git returned a malformed index record for {}",
7366                cwd.display()
7367            );
7368        };
7369        let header = &record[..tab];
7370        let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
7371        if fields.len() != 3 {
7372            bail!(
7373                "git returned a malformed index header for {}",
7374                cwd.display()
7375            );
7376        }
7377        if fields[2] != b"0" {
7378            continue;
7379        }
7380        let mode = std::str::from_utf8(fields[0])
7381            .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
7382            .to_string();
7383        let oid = std::str::from_utf8(fields[1])
7384            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7385            .to_string();
7386        entries.push(IndexEntry {
7387            path: safe_git_path(&record[tab + 1..], "index")?,
7388            mode,
7389            oid,
7390        });
7391    }
7392    Ok(entries)
7393}
7394
7395/// Share Git's complete ignore policy between call checkpoints and handoffs.
7396fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
7397    let listed = run_git_bytes(
7398        cwd,
7399        &[
7400            "ls-files",
7401            "--others",
7402            "--exclude-standard",
7403            "-z",
7404            "--",
7405            ".gitattributes",
7406            ":(glob)**/.gitattributes",
7407        ],
7408    )?;
7409    if !listed.is_empty() && !listed.ends_with(&[0]) {
7410        bail!(
7411            "git returned an unterminated attribute-file listing for {}",
7412            cwd.display()
7413        );
7414    }
7415    listed
7416        .split(|byte| *byte == 0)
7417        .filter(|record| !record.is_empty())
7418        .map(|raw| safe_git_path(raw, "attribute"))
7419        .collect()
7420}
7421
7422fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
7423    if !untracked_attribute_paths(cwd)?.is_empty() {
7424        return Ok(true);
7425    }
7426
7427    let index = index_entries(cwd)?
7428        .into_iter()
7429        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7430        .map(|entry| (entry.path, (entry.mode, entry.oid)))
7431        .collect::<BTreeMap<_, _>>();
7432    let head = tree_entries(cwd, "HEAD")?
7433        .into_iter()
7434        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7435        .map(|entry| (entry.path, (entry.mode, entry.oid)))
7436        .collect::<BTreeMap<_, _>>();
7437    if index != head {
7438        return Ok(true);
7439    }
7440
7441    let effective = check_attributes(cwd, index.keys().cloned())?;
7442    let config = CheckoutConfig::read(cwd)?;
7443    for (path, (_mode, oid)) in index {
7444        let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
7445            return Ok(true);
7446        };
7447        let attributes = effective
7448            .get(&path)
7449            .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
7450        if allows_expected_crlf(&config, attributes)? {
7451            if worktree.mode == "120000" {
7452                return Ok(true);
7453            }
7454            let (normalized, every_lf_was_crlf) =
7455                normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
7456            if !every_lf_was_crlf || normalized != oid {
7457                return Ok(true);
7458            }
7459        } else if worktree.raw_oid != oid {
7460            return Ok(true);
7461        }
7462    }
7463    Ok(false)
7464}
7465
7466fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
7467    Ok(index_entries(cwd)?
7468        .into_iter()
7469        .filter(|entry| entry.mode == "160000")
7470        .map(|entry| Gitlink {
7471            path: entry.path,
7472            oid: entry.oid,
7473        })
7474        .collect())
7475}
7476
7477fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
7478    let mut tracked = BTreeMap::new();
7479    for entry in index_entries(cwd)? {
7480        if entry.mode == "160000" {
7481            continue;
7482        }
7483        let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
7484        tracked.insert(
7485            entry.path,
7486            TrackedEntry {
7487                index_mode: entry.mode,
7488                index_oid: entry.oid,
7489                worktree,
7490            },
7491        );
7492    }
7493    Ok(tracked)
7494}
7495
7496fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
7497    let metadata = match std::fs::symlink_metadata(path) {
7498        Ok(metadata) => metadata,
7499        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7500        Err(e) => {
7501            return Err(spar_err!(
7502                "could not inspect tracked file {}: {e}",
7503                path.display()
7504            ))
7505        }
7506    };
7507    let mut fingerprint = Sha256::new();
7508    if metadata.file_type().is_symlink() {
7509        let target = std::fs::read_link(path)
7510            .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
7511        let bytes = os_str_bytes(target.as_os_str())?;
7512        fingerprint.update(b"symlink\0");
7513        fingerprint.update(&bytes);
7514        let content = Sha256::digest(&bytes).into();
7515        return Ok(Some(WorktreeFile {
7516            mode: "120000".to_string(),
7517            #[cfg(unix)]
7518            permissions: 0,
7519            raw_oid: git_blob_oid(oid_len, &bytes)?,
7520            fingerprint: fingerprint.finalize().into(),
7521            content,
7522        }));
7523    }
7524    if !metadata.is_file() {
7525        bail!("tracked path {} is not a file or symlink", path.display());
7526    }
7527
7528    let mut options = OpenOptions::new();
7529    options.read(true);
7530    #[cfg(unix)]
7531    {
7532        use std::os::unix::fs::OpenOptionsExt;
7533        options.custom_flags(libc::O_NOFOLLOW);
7534    }
7535    let mut file = options
7536        .open(path)
7537        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7538    let before = file
7539        .metadata()
7540        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7541    let mode = tracked_file_mode(&before);
7542    #[cfg(unix)]
7543    let permissions = {
7544        use std::os::unix::fs::MetadataExt;
7545        before.mode() & 0o7777
7546    };
7547    fingerprint.update(b"file\0");
7548    fingerprint.update(mode.as_bytes());
7549    #[cfg(unix)]
7550    fingerprint.update(permissions.to_le_bytes());
7551    fingerprint.update(before.len().to_le_bytes());
7552    let mut content = Sha256::new();
7553    let header = format!("blob {}\0", before.len());
7554    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7555    let mut buf = [0u8; 64 * 1024];
7556    loop {
7557        let read = file
7558            .read(&mut buf)
7559            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7560        if read == 0 {
7561            break;
7562        }
7563        fingerprint.update(&buf[..read]);
7564        content.update(&buf[..read]);
7565        object.update(&buf[..read]);
7566    }
7567    let after = file
7568        .metadata()
7569        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7570    if before.len() != after.len()
7571        || before.modified().ok() != after.modified().ok()
7572        || before.permissions() != after.permissions()
7573    {
7574        bail!(
7575            "tracked file {} changed while it was being inspected",
7576            path.display()
7577        );
7578    }
7579    let current = std::fs::symlink_metadata(path)
7580        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7581    if !same_file(&after, &current) {
7582        bail!(
7583            "tracked file {} was replaced while it was being inspected",
7584            path.display()
7585        );
7586    }
7587    Ok(Some(WorktreeFile {
7588        mode,
7589        #[cfg(unix)]
7590        permissions,
7591        raw_oid: object.finish(),
7592        fingerprint: fingerprint.finalize().into(),
7593        content: content.finalize().into(),
7594    }))
7595}
7596
7597fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
7598    let metadata = std::fs::symlink_metadata(path)
7599        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7600    let mut digest = Sha256::new();
7601    if metadata.file_type().is_symlink() {
7602        digest.update(b"symlink\0");
7603        let target = std::fs::read_link(path)
7604            .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
7605        digest.update(os_str_bytes(target.as_os_str())?);
7606        return Ok(digest.finalize().into());
7607    }
7608    if !metadata.is_file() {
7609        bail!("attribute path {} is not a file or symlink", path.display());
7610    }
7611    let mut options = OpenOptions::new();
7612    options.read(true);
7613    #[cfg(unix)]
7614    {
7615        use std::os::unix::fs::OpenOptionsExt;
7616        options.custom_flags(libc::O_NOFOLLOW);
7617    }
7618    let mut file = options
7619        .open(path)
7620        .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7621    let before = file
7622        .metadata()
7623        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7624    digest.update(b"file\0");
7625    let mut buf = [0u8; 64 * 1024];
7626    loop {
7627        let read = file
7628            .read(&mut buf)
7629            .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7630        if read == 0 {
7631            break;
7632        }
7633        digest.update(&buf[..read]);
7634    }
7635    let after = file
7636        .metadata()
7637        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7638    let current = std::fs::symlink_metadata(path)
7639        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7640    if before.len() != after.len()
7641        || before.modified().ok() != after.modified().ok()
7642        || !same_file(&after, &current)
7643    {
7644        bail!(
7645            "attribute file {} changed while it was being inspected",
7646            path.display()
7647        );
7648    }
7649    Ok(digest.finalize().into())
7650}
7651
7652enum ObjectHasher {
7653    Sha1(Sha1),
7654    Sha256(Sha256),
7655}
7656
7657impl ObjectHasher {
7658    fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
7659        let mut hasher = match oid_len {
7660            40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
7661            64 => Self::Sha256(Sha256::new()),
7662            _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
7663        };
7664        hasher.update(header);
7665        Ok(hasher)
7666    }
7667
7668    fn update(&mut self, bytes: &[u8]) {
7669        match self {
7670            Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
7671            Self::Sha256(hasher) => hasher.update(bytes),
7672        }
7673    }
7674
7675    fn finish(self) -> String {
7676        let bytes = match self {
7677            Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
7678            Self::Sha256(hasher) => hasher.finalize().to_vec(),
7679        };
7680        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
7681    }
7682}
7683
7684fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
7685    let header = format!("blob {}\0", bytes.len());
7686    let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
7687    hasher.update(bytes);
7688    Ok(hasher.finish())
7689}
7690
7691fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
7692    let mut first = open_regular_file(path)?;
7693    let first_before = first
7694        .metadata()
7695        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7696    let mut raw_len = 0u64;
7697    let mut crlf_pairs = 0u64;
7698    let mut previous_was_cr = false;
7699    let mut every_lf_was_crlf = true;
7700    let mut buf = [0u8; 64 * 1024];
7701    loop {
7702        let read = first
7703            .read(&mut buf)
7704            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7705        if read == 0 {
7706            break;
7707        }
7708        raw_len = raw_len
7709            .checked_add(read as u64)
7710            .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
7711        for byte in &buf[..read] {
7712            if *byte == b'\n' {
7713                if previous_was_cr {
7714                    crlf_pairs += 1;
7715                } else {
7716                    every_lf_was_crlf = false;
7717                }
7718            }
7719            previous_was_cr = *byte == b'\r';
7720        }
7721    }
7722    let first_after = first
7723        .metadata()
7724        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7725    let current = std::fs::symlink_metadata(path)
7726        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7727    if raw_len != first_before.len()
7728        || !stable_file_metadata(&first_before, &first_after)
7729        || !stable_file_metadata(&first_after, &current)
7730    {
7731        bail!(
7732            "tracked file {} changed while line endings were inspected",
7733            path.display()
7734        );
7735    }
7736
7737    let normalized_len = raw_len
7738        .checked_sub(crlf_pairs)
7739        .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
7740    let header = format!("blob {normalized_len}\0");
7741    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7742    let mut second = open_regular_file(path)?;
7743    let second_before = second
7744        .metadata()
7745        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7746    if !stable_file_metadata(&first_after, &second_before) {
7747        bail!(
7748            "tracked file {} changed between line-ending checks",
7749            path.display()
7750        );
7751    }
7752    let mut pending_cr = false;
7753    loop {
7754        let read = second
7755            .read(&mut buf)
7756            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7757        if read == 0 {
7758            break;
7759        }
7760        for byte in &buf[..read] {
7761            if pending_cr {
7762                if *byte == b'\n' {
7763                    object.update(b"\n");
7764                    pending_cr = false;
7765                    continue;
7766                }
7767                object.update(b"\r");
7768                pending_cr = false;
7769            }
7770            if *byte == b'\r' {
7771                pending_cr = true;
7772            } else {
7773                object.update(std::slice::from_ref(byte));
7774            }
7775        }
7776    }
7777    if pending_cr {
7778        object.update(b"\r");
7779    }
7780    let second_after = second
7781        .metadata()
7782        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7783    let current = std::fs::symlink_metadata(path)
7784        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7785    if !stable_file_metadata(&second_before, &second_after)
7786        || !stable_file_metadata(&second_after, &current)
7787    {
7788        bail!(
7789            "tracked file {} changed while line endings were hashed",
7790            path.display()
7791        );
7792    }
7793    Ok((object.finish(), every_lf_was_crlf))
7794}
7795
7796fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7797    let mut options = OpenOptions::new();
7798    options.read(true);
7799    #[cfg(unix)]
7800    {
7801        use std::os::unix::fs::OpenOptionsExt;
7802        options.custom_flags(libc::O_NOFOLLOW);
7803    }
7804    let file = options
7805        .open(path)
7806        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7807    let metadata = file
7808        .metadata()
7809        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7810    if !metadata.is_file() {
7811        bail!("tracked path {} is not a regular file", path.display());
7812    }
7813    Ok(file)
7814}
7815
7816fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7817    if !same_file(left, right)
7818        || left.len() != right.len()
7819        || left.modified().ok() != right.modified().ok()
7820        || left.permissions() != right.permissions()
7821    {
7822        return false;
7823    }
7824    #[cfg(unix)]
7825    {
7826        use std::os::unix::fs::MetadataExt;
7827        left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
7828    }
7829    #[cfg(not(unix))]
7830    {
7831        left.created().ok() == right.created().ok()
7832    }
7833}
7834
7835/// How many paths one `check-attr` call is asked about.
7836///
7837/// `check-attr` answers with three NUL fields per attribute per path, so the
7838/// output is several times the input. A whole monorepo in one call is megabytes
7839/// in both directions, and the 30 second timeout below is a timeout on the
7840/// batch. Batching keeps each call small enough to be honest about.
7841const ATTRIBUTE_BATCH: usize = 1000;
7842
7843fn check_attributes(
7844    cwd: &Path,
7845    paths: impl IntoIterator<Item = PathBuf>,
7846) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7847    let paths = paths.into_iter().collect::<BTreeSet<_>>();
7848    if paths.is_empty() {
7849        return Ok(BTreeMap::new());
7850    }
7851    let ordered = paths.into_iter().collect::<Vec<_>>();
7852    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7853    for batch in ordered.chunks(ATTRIBUTE_BATCH) {
7854        values.extend(check_attribute_batch(cwd, batch)?);
7855    }
7856    Ok(values)
7857}
7858
7859fn check_attribute_batch(
7860    cwd: &Path,
7861    batch: &[PathBuf],
7862) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7863    const NAMES: [&str; 6] = [
7864        "filter",
7865        "working-tree-encoding",
7866        "ident",
7867        "text",
7868        "eol",
7869        "crlf",
7870    ];
7871    let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
7872    let mut input = String::new();
7873    for path in &paths {
7874        let path = path.to_str().ok_or_else(|| {
7875            spar_err!(
7876                "cannot inspect attributes for a non-UTF-8 path in {}",
7877                cwd.display()
7878            )
7879        })?;
7880        input.push_str(path);
7881        input.push('\0');
7882    }
7883    let argv = git_without_automation_argv(&[
7884        "check-attr",
7885        "-z",
7886        "--cached",
7887        "--stdin",
7888        "filter",
7889        "working-tree-encoding",
7890        "ident",
7891        "text",
7892        "eol",
7893        "crlf",
7894    ]);
7895    let output = proc::run_bytes(
7896        &argv,
7897        &ExecOpts::new()
7898            .cwd(cwd)
7899            .timeout_secs(30)
7900            .stdin(input)
7901            .stop_descendants(true),
7902    )?;
7903    if !output.is_empty() && !output.ends_with(&[0]) {
7904        bail!(
7905            "git returned an unterminated attribute result for {}",
7906            cwd.display()
7907        );
7908    }
7909    let fields = output
7910        .split(|byte| *byte == 0)
7911        .filter(|field| !field.is_empty())
7912        .collect::<Vec<_>>();
7913    if fields.len() != paths.len() * NAMES.len() * 3 {
7914        bail!(
7915            "git returned an unexpected attribute result for {}",
7916            cwd.display()
7917        );
7918    }
7919    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7920    for record in fields.chunks_exact(3) {
7921        let path = safe_git_path(record[0], "attribute")?;
7922        if !paths.contains(&path) {
7923            bail!(
7924                "git returned attributes for the wrong path in {}",
7925                cwd.display()
7926            );
7927        }
7928        let name = std::str::from_utf8(record[1])
7929            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
7930        let value = std::str::from_utf8(record[2])
7931            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
7932        values
7933            .entry(path)
7934            .or_default()
7935            .insert(name.to_string(), value.to_string());
7936    }
7937    if paths.iter().any(|path| {
7938        values
7939            .get(path)
7940            .is_none_or(|attributes| attributes.len() != NAMES.len())
7941    }) {
7942        bail!(
7943            "git omitted an attribute result for a tracked path in {}",
7944            cwd.display()
7945        );
7946    }
7947    Ok(values)
7948}
7949
7950fn attribute_is_active(value: Option<&String>) -> bool {
7951    !matches!(
7952        value.map(String::as_str),
7953        None | Some("unspecified") | Some("unset")
7954    )
7955}
7956
7957fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
7958    attribute_is_active(values.get("filter"))
7959        || attribute_is_active(values.get("working-tree-encoding"))
7960}
7961
7962/// The checkout settings a per-file line-ending decision depends on.
7963///
7964/// These are constant for a worktree, but the checks that read them run once
7965/// per tracked file, and each read was its own `git config` process. A
7966/// thousand-file repository with no `.gitattributes` takes every one of those
7967/// branches, which is half a minute of process spawning per scan, and the sweep
7968/// before a run scans every finished worktree more than once. Read them here,
7969/// once, and hand them down.
7970struct CheckoutConfig {
7971    autocrlf: Option<String>,
7972    eol: Option<String>,
7973    symlinks: Option<bool>,
7974}
7975
7976impl CheckoutConfig {
7977    fn read(cwd: &Path) -> Result<Self> {
7978        Ok(Self {
7979            autocrlf: git_config_value(cwd, "core.autocrlf")?,
7980            eol: git_config_value(cwd, "core.eol")?,
7981            symlinks: git_config_bool(cwd, "core.symlinks")?,
7982        })
7983    }
7984
7985    fn autocrlf_is_true(&self) -> bool {
7986        self.autocrlf.as_ref().is_some_and(|value| {
7987            matches!(
7988                value.to_ascii_lowercase().as_str(),
7989                "true" | "yes" | "on" | "1"
7990            )
7991        })
7992    }
7993
7994    fn eol_is(&self, wanted: &str) -> bool {
7995        self.eol
7996            .as_ref()
7997            .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
7998    }
7999}
8000
8001fn path_has_ambiguous_transform(
8002    config: &CheckoutConfig,
8003    values: &BTreeMap<String, String>,
8004) -> Result<bool> {
8005    if path_has_external_transform(values)
8006        || attribute_is_active(values.get("ident"))
8007        || attribute_is_active(values.get("crlf"))
8008    {
8009        return Ok(true);
8010    }
8011    let text = values.get("text").map(String::as_str);
8012    let eol = values.get("eol").map(String::as_str);
8013    if text == Some("auto") {
8014        return Ok(true);
8015    }
8016    if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
8017        || !matches!(
8018            eol,
8019            Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
8020        )
8021    {
8022        return Ok(true);
8023    }
8024    if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
8025        return Ok(config.autocrlf_is_true());
8026    }
8027    Ok(false)
8028}
8029
8030fn allows_expected_crlf(
8031    config: &CheckoutConfig,
8032    values: &BTreeMap<String, String>,
8033) -> Result<bool> {
8034    if path_has_external_transform(values)
8035        || attribute_is_active(values.get("ident"))
8036        || attribute_is_active(values.get("crlf"))
8037    {
8038        return Ok(false);
8039    }
8040    let text = values.get("text").map(String::as_str);
8041    let eol = values.get("eol").map(String::as_str);
8042    if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
8043        return Ok(false);
8044    }
8045    if eol == Some("crlf") {
8046        return Ok(true);
8047    }
8048    if text != Some("set") {
8049        return Ok(false);
8050    }
8051    if let Some(autocrlf) = config.autocrlf.as_deref() {
8052        match autocrlf.to_ascii_lowercase().as_str() {
8053            "true" | "yes" | "on" | "1" => return Ok(true),
8054            "input" => return Ok(false),
8055            _ => {}
8056        }
8057    }
8058    if config.eol_is("crlf") {
8059        return Ok(true);
8060    }
8061    #[cfg(windows)]
8062    if config.eol.is_none() || config.eol_is("native") {
8063        return Ok(true);
8064    }
8065    Ok(false)
8066}
8067
8068fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
8069    let argv = git_without_automation_argv(&["config", "--get", key]);
8070    let output = proc::exec(
8071        &argv,
8072        &ExecOpts::new()
8073            .cwd(cwd)
8074            .timeout_secs(30)
8075            .check(false)
8076            .stop_descendants(true),
8077    )?;
8078    match output.code {
8079        0 => Ok(Some(output.stdout.trim().to_string())),
8080        1 => Ok(None),
8081        _ => bail!(
8082            "could not read Git configuration in {}: {}",
8083            cwd.display(),
8084            output.stderr.trim()
8085        ),
8086    }
8087}
8088
8089fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
8090    let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
8091    let output = proc::exec(
8092        &argv,
8093        &ExecOpts::new()
8094            .cwd(cwd)
8095            .timeout_secs(30)
8096            .check(false)
8097            .stop_descendants(true),
8098    )?;
8099    match output.code {
8100        0 if output.stdout.trim() == "true" => Ok(Some(true)),
8101        0 if output.stdout.trim() == "false" => Ok(Some(false)),
8102        0 => bail!(
8103            "git returned an invalid boolean for {key} in {}",
8104            cwd.display()
8105        ),
8106        1 => Ok(None),
8107        _ => bail!(
8108            "could not read Git configuration in {}: {}",
8109            cwd.display(),
8110            output.stderr.trim()
8111        ),
8112    }
8113}
8114
8115#[cfg(unix)]
8116fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
8117    use std::os::unix::fs::PermissionsExt;
8118    if metadata.permissions().mode() & 0o111 == 0 {
8119        "100644".to_string()
8120    } else {
8121        "100755".to_string()
8122    }
8123}
8124
8125#[cfg(not(unix))]
8126fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
8127    "100644".to_string()
8128}
8129
8130fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
8131    let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
8132    if !listed.is_empty() && !listed.ends_with(&[0]) {
8133        bail!(
8134            "git returned an unterminated tree listing for {}",
8135            cwd.display()
8136        );
8137    }
8138    let mut entries = Vec::new();
8139    for record in listed
8140        .split(|byte| *byte == 0)
8141        .filter(|record| !record.is_empty())
8142    {
8143        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
8144            bail!("git returned a malformed tree record for {}", cwd.display());
8145        };
8146        let fields = record[..tab]
8147            .split(|byte| *byte == b' ')
8148            .collect::<Vec<_>>();
8149        if fields.len() != 3 {
8150            bail!("git returned a malformed tree header for {}", cwd.display());
8151        }
8152        let mode = std::str::from_utf8(fields[0])
8153            .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
8154            .to_string();
8155        let oid = std::str::from_utf8(fields[2])
8156            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
8157            .to_string();
8158        entries.push(IndexEntry {
8159            path: safe_git_path(&record[tab + 1..], "tree")?,
8160            mode,
8161            oid,
8162        });
8163    }
8164    Ok(entries)
8165}
8166
8167fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
8168    Ok(tree_entries(cwd, "HEAD")?
8169        .into_iter()
8170        .filter(|entry| entry.mode == "160000")
8171        .map(|entry| (entry.path, entry.oid))
8172        .collect())
8173}
8174
8175fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
8176    let head = head_gitlinks(cwd)?;
8177    let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
8178        .into_iter()
8179        .map(|link| (link.path, link.oid))
8180        .collect();
8181    let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
8182    paths.extend(index.keys().cloned());
8183    Ok(paths
8184        .into_iter()
8185        .filter(|path| head.get(path) != index.get(path))
8186        .collect())
8187}
8188
8189fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
8190    let path = parent.join(relative);
8191    let metadata = match std::fs::symlink_metadata(&path) {
8192        Ok(metadata) => metadata,
8193        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8194        Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
8195    };
8196    if !metadata.is_dir() {
8197        bail!("the gitlink at {} is not a directory", path.display());
8198    }
8199    let canonical = std::fs::canonicalize(&path)
8200        .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
8201    if canonical != path {
8202        bail!(
8203            "the gitlink at {} resolves through a symlink",
8204            path.display()
8205        );
8206    }
8207    if !path.join(".git").exists() {
8208        let empty = std::fs::read_dir(&path)
8209            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
8210            .next()
8211            .is_none();
8212        if empty {
8213            return Ok(None);
8214        }
8215        bail!(
8216            "the uninitialized gitlink at {} contains local files",
8217            path.display()
8218        );
8219    }
8220    let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
8221    if inside.trim() != "true" {
8222        bail!("the gitlink at {} is not a worktree", path.display());
8223    }
8224    let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
8225    let top = std::fs::canonicalize(top.trim()).map_err(|e| {
8226        spar_err!(
8227            "could not resolve the gitlink top level at {}: {e}",
8228            path.display()
8229        )
8230    })?;
8231    if top != canonical {
8232        bail!(
8233            "the gitlink at {} belongs to a different worktree",
8234            path.display()
8235        );
8236    }
8237    Ok(Some(canonical))
8238}
8239
8240/// A Git entry the outer repository cannot account for.
8241///
8242/// A checkout Git itself reports as a nested repository is expected. Its
8243/// directory is recorded by identity in the untracked snapshot and keeps the
8244/// worktree from being released, so a reviewer that clones a reproduction into
8245/// ignored build output neither loses that clone nor fails the run over it.
8246/// The scan does not descend into one, because what is under it belongs to
8247/// that repository.
8248///
8249/// A Git entry Git does not report is the one worth stopping for. Running
8250/// `git init` inside a directory that already holds tracked files leaves
8251/// nothing in any listing while making every file written under it invisible
8252/// to the outer worktree, so SPAR can no longer prove what is there.
8253fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
8254    let root = std::fs::canonicalize(cwd)
8255        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8256    let mut allowed = BTreeSet::from([root.join(".git")]);
8257    let mut checkouts = BTreeSet::new();
8258    let mut repositories = vec![root.clone()];
8259    let mut visited = BTreeSet::new();
8260    while let Some(repository) = repositories.pop() {
8261        let canonical = std::fs::canonicalize(&repository)
8262            .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8263        if !visited.insert(canonical.clone()) {
8264            bail!("submodule recursion revisited {}", canonical.display());
8265        }
8266        for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
8267            let (path, nested) = untracked_record(&raw, "untracked")?;
8268            if nested {
8269                checkouts.insert(canonical.join(path));
8270            }
8271        }
8272        for link in gitlinks(&canonical)? {
8273            let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
8274                continue;
8275            };
8276            allowed.insert(submodule.join(".git"));
8277            repositories.push(submodule);
8278        }
8279    }
8280
8281    // A build deletes its own directories while this walks them. A directory
8282    // that is gone holds no Git entry, so skipping it answers the question
8283    // being asked; erroring instead ended runs whose worktrees were fine.
8284    let scan_root = root.clone();
8285    let mut directories = vec![root];
8286    while let Some(directory) = directories.pop() {
8287        let entries = match std::fs::read_dir(&directory) {
8288            Ok(entries) => entries,
8289            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8290            Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8291        };
8292        for entry in entries {
8293            let entry = match entry {
8294                Ok(entry) => entry,
8295                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8296                Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8297            };
8298            let path = entry.path();
8299            if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
8300                continue;
8301            }
8302            if entry.file_name() == OsStr::new(".git") {
8303                if !allowed.contains(&path) {
8304                    return Ok(Some(path));
8305                }
8306                continue;
8307            }
8308            let kind = match entry.file_type() {
8309                Ok(kind) => kind,
8310                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8311                Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
8312            };
8313            if kind.is_dir() && !checkouts.contains(&path) {
8314                directories.push(path);
8315            }
8316        }
8317    }
8318    Ok(None)
8319}
8320
8321pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
8322    if let Some(path) = unexpected_nested_git_entry(cwd)? {
8323        bail!(
8324            "the worktree contains a Git entry at {} that Git does not report as a nested \
8325             checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
8326             prove they are unchanged.",
8327            path.display()
8328        );
8329    }
8330    let root = std::fs::canonicalize(cwd)
8331        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8332    let mut repositories = BTreeMap::new();
8333    let mut visited = BTreeSet::new();
8334    collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
8335    Ok(GitState { repositories })
8336}
8337
8338fn collect_git_state(
8339    repository: &Path,
8340    prefix: &Path,
8341    visited: &mut BTreeSet<PathBuf>,
8342    repositories: &mut BTreeMap<PathBuf, RepositoryState>,
8343) -> Result<()> {
8344    let canonical = std::fs::canonicalize(repository)
8345        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8346    if !visited.insert(canonical.clone()) {
8347        bail!("submodule recursion revisited {}", canonical.display());
8348    }
8349    let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8350    let head = head.trim().to_string();
8351    if head.is_empty() {
8352        bail!("git returned an empty head for {}", repository.display());
8353    }
8354    let unsafe_index_flags = unsafe_index_flags(repository)?;
8355    let tracked = tracked_entries(repository)?;
8356    let gitlinks = gitlinks(repository)?;
8357    if repositories
8358        .insert(
8359            prefix.to_path_buf(),
8360            RepositoryState {
8361                head,
8362                unsafe_index_flags,
8363                tracked,
8364                gitlinks: gitlinks
8365                    .iter()
8366                    .map(|link| (link.path.clone(), link.oid.clone()))
8367                    .collect(),
8368            },
8369        )
8370        .is_some()
8371    {
8372        bail!("Git state contains duplicate repository path {:?}", prefix);
8373    }
8374
8375    for link in gitlinks {
8376        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
8377            continue;
8378        };
8379        collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
8380    }
8381    Ok(())
8382}
8383
8384fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
8385    let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
8386    if !listed.is_empty() && !listed.ends_with(&[0]) {
8387        bail!(
8388            "git returned an unterminated index-flag listing for {}",
8389            cwd.display()
8390        );
8391    }
8392    let mut unsafe_records = Vec::new();
8393    for record in listed
8394        .split(|byte| *byte == 0)
8395        .filter(|record| !record.is_empty())
8396    {
8397        if record.len() < 3 || record[1] != b' ' {
8398            bail!(
8399                "git returned a malformed index-flag record for {}",
8400                cwd.display()
8401            );
8402        }
8403        if record[0] != b'H' {
8404            unsafe_records.extend_from_slice(record);
8405            unsafe_records.push(0);
8406        }
8407    }
8408    Ok(unsafe_records)
8409}
8410
8411pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
8412    safe_git_state(cwd).map(|_| ())
8413}
8414
8415pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
8416    let state = git_state(cwd)?;
8417    if let Some((path, _repository)) = state
8418        .repositories
8419        .iter()
8420        .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
8421    {
8422        let label = if path.as_os_str().is_empty() {
8423            cwd.to_path_buf()
8424        } else {
8425            cwd.join(path)
8426        };
8427        bail!(
8428            "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
8429             SPAR cannot prove the working files are unchanged, so it was kept.",
8430            label.display()
8431        );
8432    }
8433    Ok(state)
8434}
8435
8436fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
8437    repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
8438}
8439
8440fn repository_has_recoverable_work_with(
8441    cwd: &Path,
8442    include_ignored: bool,
8443    disposable: Disposable,
8444) -> Result<bool> {
8445    if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
8446        return Ok(true);
8447    }
8448    let mut visited = BTreeSet::new();
8449    repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
8450}
8451
8452fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
8453    let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
8454    let git_dir = PathBuf::from(git_dir.trim());
8455    let git_dir = if git_dir.is_absolute() {
8456        git_dir
8457    } else {
8458        cwd.join(git_dir)
8459    };
8460    let git_dir = std::fs::canonicalize(&git_dir)
8461        .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
8462    match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
8463        Ok(_) => return Ok(true),
8464        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8465        Err(error) => {
8466            return Err(spar_err!(
8467                "could not inspect per-worktree configuration in {}: {error}",
8468                git_dir.display()
8469            ))
8470        }
8471    }
8472
8473    let orig_head = git_dir.join("ORIG_HEAD");
8474    match std::fs::symlink_metadata(&orig_head) {
8475        Ok(metadata) if metadata.is_file() => {
8476            let oid = std::fs::read_to_string(&orig_head)
8477                .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
8478            let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
8479                return Ok(true);
8480            };
8481            if !commit_has_shared_ref(cwd, &commit)? {
8482                return Ok(true);
8483            }
8484        }
8485        Ok(_) => return Ok(true),
8486        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8487        Err(error) => {
8488            return Err(spar_err!(
8489                "could not inspect {}: {error}",
8490                orig_head.display()
8491            ))
8492        }
8493    }
8494
8495    let edit_message = git_dir.join("COMMIT_EDITMSG");
8496    match std::fs::symlink_metadata(&edit_message) {
8497        Ok(metadata) if metadata.is_file() => {
8498            let draft = std::fs::read(&edit_message)
8499                .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
8500            if draft != head_commit_message(cwd)? {
8501                return Ok(true);
8502            }
8503        }
8504        Ok(_) => return Ok(true),
8505        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8506        Err(error) => {
8507            return Err(spar_err!(
8508                "could not inspect {}: {error}",
8509                edit_message.display()
8510            ))
8511        }
8512    }
8513
8514    if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
8515        return Ok(true);
8516    }
8517
8518    let local_refs = run_git_bytes(
8519        cwd,
8520        &[
8521            "for-each-ref",
8522            "--format=%(refname)",
8523            "refs/worktree",
8524            "refs/bisect",
8525            "refs/rewritten",
8526        ],
8527    )?;
8528    if !local_refs.is_empty() {
8529        return Ok(true);
8530    }
8531
8532    // `FETCH_HEAD` only names what a `git fetch` in this worktree brought
8533    // down. The objects live in the shared store and the next fetch rewrites
8534    // it, so an agent that fetched once must not keep the checkout forever.
8535    for entry in std::fs::read_dir(&git_dir)
8536        .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
8537    {
8538        let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
8539        let known = matches!(
8540            entry.file_name().to_str(),
8541            Some(
8542                "HEAD"
8543                    | "ORIG_HEAD"
8544                    | "COMMIT_EDITMSG"
8545                    | "FETCH_HEAD"
8546                    | "commondir"
8547                    | "gitdir"
8548                    | "index"
8549                    | "logs"
8550                    | "refs"
8551            )
8552        );
8553        if !known {
8554            return Ok(true);
8555        }
8556    }
8557
8558    let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8559    if !commit_has_shared_ref(cwd, head.trim())? {
8560        return Ok(true);
8561    }
8562    Ok(false)
8563}
8564
8565fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
8566    let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
8567    let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
8568        bail!(
8569            "git returned a commit without a message separator in {}",
8570            cwd.display()
8571        );
8572    };
8573    Ok(commit[split + 2..].to_vec())
8574}
8575
8576fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
8577    let metadata = match std::fs::symlink_metadata(logs) {
8578        Ok(metadata) => metadata,
8579        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
8580        Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
8581    };
8582    if !metadata.is_dir() {
8583        return Ok(true);
8584    }
8585    let mut files = Vec::new();
8586    let mut directories = vec![logs.to_path_buf()];
8587    while let Some(directory) = directories.pop() {
8588        for entry in std::fs::read_dir(&directory)
8589            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
8590        {
8591            let entry =
8592                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
8593            let path = entry.path();
8594            let kind = entry
8595                .file_type()
8596                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
8597            if kind.is_dir() {
8598                directories.push(path);
8599            } else if kind.is_file() {
8600                files.push(path);
8601            } else {
8602                return Ok(true);
8603            }
8604        }
8605    }
8606
8607    let mut commits = BTreeSet::new();
8608    for path in files {
8609        if !collect_reflog_commits(cwd, &path, &mut commits)? {
8610            return Ok(true);
8611        }
8612    }
8613    for commit in commits {
8614        if !commit_has_shared_ref(cwd, &commit)? {
8615            return Ok(true);
8616        }
8617    }
8618    Ok(false)
8619}
8620
8621/// Every commit named by one ref's common reflog must survive deletion of that
8622/// ref. Ancestors of a durable current tip survive with the tip; divergent
8623/// entries need another shared ref of their own.
8624fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
8625    let common = common_git_dir(cwd)?;
8626    let reflog = common.join("logs").join(refname);
8627    let metadata = match std::fs::symlink_metadata(&reflog) {
8628        Ok(metadata) => metadata,
8629        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
8630        Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
8631    };
8632    if !metadata.is_file() {
8633        return Ok(false);
8634    }
8635    let mut commits = BTreeSet::new();
8636    if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
8637        return Ok(false);
8638    }
8639    for commit in commits {
8640        if is_ancestor(cwd, &commit, durable_tip)?
8641            || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
8642        {
8643            continue;
8644        }
8645        return Ok(false);
8646    }
8647    Ok(true)
8648}
8649
8650fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
8651    let file = std::fs::File::open(path)
8652        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8653    for line in std::io::BufReader::new(file).lines() {
8654        let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8655        let mut fields = line.splitn(3, ' ');
8656        let Some(old) = fields.next() else {
8657            return Ok(false);
8658        };
8659        let Some(new) = fields.next() else {
8660            return Ok(false);
8661        };
8662        if fields.next().is_none() {
8663            return Ok(false);
8664        }
8665        for oid in [old, new] {
8666            if oid.bytes().all(|byte| byte == b'0') {
8667                continue;
8668            }
8669            let Some(commit) = resolve_optional_commit(cwd, oid)? else {
8670                return Ok(false);
8671            };
8672            commits.insert(commit);
8673        }
8674    }
8675    Ok(true)
8676}
8677
8678fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
8679    let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
8680    let path = PathBuf::from(raw.trim());
8681    let path = if path.is_absolute() {
8682        path
8683    } else {
8684        cwd.join(path)
8685    };
8686    std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
8687}
8688
8689fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
8690    let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
8691    let output = proc::exec(
8692        &argv,
8693        &ExecOpts::new()
8694            .cwd(cwd)
8695            .timeout_secs(30)
8696            .check(false)
8697            .stop_descendants(true),
8698    )?;
8699    match output.code {
8700        0 => Ok(true),
8701        1 => Ok(false),
8702        _ => bail!("{}", proc::failure_message(&argv, &output)),
8703    }
8704}
8705
8706fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
8707    let commit = format!("{oid}^{{commit}}");
8708    let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
8709    let output = proc::exec(
8710        &argv,
8711        &ExecOpts::new()
8712            .cwd(cwd)
8713            .timeout_secs(30)
8714            .check(false)
8715            .stop_descendants(true),
8716    )?;
8717    if output.code != 0 {
8718        return Ok(None);
8719    }
8720    let oid = output.stdout.trim();
8721    if oid.is_empty() {
8722        return Ok(None);
8723    }
8724    Ok(Some(oid.to_string()))
8725}
8726
8727fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
8728    commit_has_shared_ref_except(cwd, oid, None)
8729}
8730
8731fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
8732    let contains = format!("--contains={oid}");
8733    let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
8734    Ok(shared.split(|byte| *byte == b'\n').any(|record| {
8735        !record.is_empty()
8736            && !record.starts_with(b"refs/worktree/")
8737            && !record.starts_with(b"refs/bisect/")
8738            && !record.starts_with(b"refs/rewritten/")
8739            && exclude.is_none_or(|excluded| record != excluded.as_bytes())
8740    }))
8741}
8742
8743/// Whether removing the worktree would take away an untracked file somebody
8744/// might want back.
8745///
8746/// Ordinary untracked files always count. So does an ignored file outside the
8747/// known build and cache directories, because an ignored path is only a path
8748/// Git was told not to track, which is where a local `.env` lives as readily as
8749/// compiler output.
8750///
8751/// Recognized build and cache output does not. A managed commit already leaves
8752/// it out rather than treating it as work, and the command that wrote it writes
8753/// it again. Counting it kept every worktree whose tests or build had run,
8754/// which is nearly all of them, so a merged pull request still left its
8755/// checkout behind. A repository nested in that output is somebody else's
8756/// history and counts whatever it sits under.
8757fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
8758    let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
8759    for raw in &ordinary {
8760        let (path, _) = untracked_record(raw, "untracked")?;
8761        if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
8762            continue;
8763        }
8764        return Ok(true);
8765    }
8766    let listed = untracked_listing(
8767        cwd,
8768        &[
8769            "ls-files",
8770            "--others",
8771            "--ignored",
8772            "--exclude-standard",
8773            "-z",
8774        ],
8775    )?;
8776    for raw in listed {
8777        let (path, nested) = untracked_record(&raw, "ignored")?;
8778        // A repository cloned into the output keeps the checkout whatever the
8779        // mode: its objects exist nowhere else, which is the one thing a
8780        // rebuild from origin cannot replace.
8781        if nested {
8782            return Ok(true);
8783        }
8784        if disposable == Disposable::BuildOutput
8785            && !is_generated_artifact(&path)
8786            && !is_desktop_metadata(&path)
8787        {
8788            return Ok(true);
8789        }
8790    }
8791    Ok(false)
8792}
8793
8794fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8795    let listed = run_git_bytes(cwd, args)?;
8796    if !listed.is_empty() && !listed.ends_with(&[0]) {
8797        bail!(
8798            "git returned an unterminated untracked-file list for {}",
8799            cwd.display()
8800        );
8801    }
8802    Ok(listed
8803        .split(|byte| *byte == 0)
8804        .filter(|raw| !raw.is_empty())
8805        .map(|raw| raw.to_vec())
8806        .collect())
8807}
8808
8809fn repository_has_recoverable_work_inner(
8810    cwd: &Path,
8811    include_ignored: bool,
8812    disposable: Disposable,
8813    visited: &mut BTreeSet<PathBuf>,
8814) -> Result<bool> {
8815    let canonical = std::fs::canonicalize(cwd)
8816        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8817    if !visited.insert(canonical.clone()) {
8818        bail!("submodule recursion revisited {}", canonical.display());
8819    }
8820    if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
8821        return Ok(true);
8822    }
8823    if !unsafe_index_flags(cwd)?.is_empty() {
8824        return Ok(true);
8825    }
8826    if attributes_may_be_modified(cwd)? {
8827        return Ok(true);
8828    }
8829    if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
8830        return Ok(true);
8831    }
8832    if include_ignored {
8833        let index = index_entries(cwd)?
8834            .into_iter()
8835            .map(|entry| (entry.path, (entry.mode, entry.oid)))
8836            .collect::<BTreeMap<_, _>>();
8837        let head = tree_entries(cwd, "HEAD")?
8838            .into_iter()
8839            .map(|entry| (entry.path, (entry.mode, entry.oid)))
8840            .collect::<BTreeMap<_, _>>();
8841        if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
8842            return Ok(true);
8843        }
8844        let tracked = tracked_entries(cwd)?;
8845        let effective = check_attributes(cwd, tracked.keys().cloned())?;
8846        let config = CheckoutConfig::read(cwd)?;
8847        for (path, entry) in tracked {
8848            let Some(worktree) = entry.worktree else {
8849                return Ok(true);
8850            };
8851            let attributes = effective.get(&path).ok_or_else(|| {
8852                spar_err!("git omitted attributes for {}", cwd.join(&path).display())
8853            })?;
8854            if path_has_ambiguous_transform(&config, attributes)? {
8855                return Ok(true);
8856            }
8857            let symlink_file = entry.index_mode == "120000"
8858                && worktree.mode == "100644"
8859                && worktree.raw_oid == entry.index_oid
8860                && config.symlinks == Some(false);
8861            if worktree.mode != entry.index_mode && !symlink_file {
8862                return Ok(true);
8863            }
8864            if entry.index_mode == "120000" {
8865                if worktree.raw_oid != entry.index_oid {
8866                    return Ok(true);
8867                }
8868                continue;
8869            }
8870            #[cfg(unix)]
8871            {
8872                let expected = if entry.index_mode == "100755" {
8873                    0o755
8874                } else {
8875                    0o644
8876                };
8877                if worktree.permissions != expected {
8878                    return Ok(true);
8879                }
8880            }
8881            if allows_expected_crlf(&config, attributes)? {
8882                let (normalized, every_lf_was_crlf) =
8883                    normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
8884                if !every_lf_was_crlf || normalized != entry.index_oid {
8885                    return Ok(true);
8886                }
8887            } else if worktree.raw_oid != entry.index_oid {
8888                return Ok(true);
8889            }
8890        }
8891    } else {
8892        let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
8893        if !run_git_bytes(cwd, &args)?.is_empty() {
8894            return Ok(true);
8895        }
8896    }
8897    for link in gitlinks(cwd)? {
8898        let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
8899            continue;
8900        };
8901        // Git stores a linked worktree's initialized submodule objects under
8902        // that worktree's administrative directory. Ordinary removal cannot
8903        // prove a local submodule commit exists anywhere else, even when both
8904        // working trees look clean.
8905        if include_ignored {
8906            return Ok(true);
8907        }
8908        let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8909        if head.trim() != link.oid {
8910            return Ok(true);
8911        }
8912        if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
8913        {
8914            return Ok(true);
8915        }
8916    }
8917    Ok(false)
8918}
8919
8920pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
8921    repository_has_recoverable_work(cwd, false)
8922}
8923
8924fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
8925    let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
8926    Ok(!run_git_bytes(cwd, &args)?.is_empty())
8927}
8928
8929#[cfg(unix)]
8930fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8931    use std::os::unix::ffi::OsStringExt;
8932    Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
8933}
8934
8935#[cfg(not(unix))]
8936fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8937    String::from_utf8(raw.to_vec())
8938        .map(PathBuf::from)
8939        .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
8940}
8941
8942/// Fingerprint the directory of a nested repository without reading inside it.
8943///
8944/// The files under it are that repository's, not this one's. They are recorded
8945/// against its own baseline whenever SPAR works there, and a run of its own may
8946/// legitimately add or remove entries while this call is in flight, so the
8947/// volatile directory fields stay out of the fingerprint. Identity and type
8948/// remain, which is what makes deleting the checkout, or replacing it with a
8949/// file, observable from the outer worktree.
8950/// What a path Git listed looks like once it is no longer there.
8951///
8952/// A reviewer building the project deletes and rewrites its output constantly,
8953/// so a path can go between the listing that named it and the stat that
8954/// measures it. Treating that as an unreadable worktree ended runs that had
8955/// nothing wrong with them. The record stays in the snapshot rather than being
8956/// dropped, so a path that vanishes cannot pass for one that was never there.
8957fn vanished_untracked_file() -> UntrackedFile {
8958    UntrackedFile {
8959        kind: 0,
8960        len: 0,
8961        modified: None,
8962        created: None,
8963        readonly: false,
8964        symlink_target: None,
8965        #[cfg(unix)]
8966        device: 0,
8967        #[cfg(unix)]
8968        inode: 0,
8969        #[cfg(unix)]
8970        mode: 0,
8971        #[cfg(unix)]
8972        change_seconds: 0,
8973        #[cfg(unix)]
8974        change_nanoseconds: 0,
8975    }
8976}
8977
8978fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
8979    let metadata = match std::fs::symlink_metadata(path) {
8980        Ok(metadata) => metadata,
8981        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8982        Err(e) => {
8983            return Err(spar_err!(
8984                "could not inspect the nested repository at {}: {e}",
8985                path.display()
8986            ))
8987        }
8988    };
8989    if !metadata.is_dir() {
8990        bail!(
8991            "git reported {} as a nested repository, but it is not a directory",
8992            path.display()
8993        );
8994    }
8995    if std::fs::symlink_metadata(path.join(".git")).is_err() {
8996        bail!(
8997            "git reported {} as a nested repository, but it has no Git entry",
8998            path.display()
8999        );
9000    }
9001    #[cfg(unix)]
9002    {
9003        use std::os::unix::fs::MetadataExt;
9004        Ok(UntrackedFile {
9005            kind: 3,
9006            len: 0,
9007            modified: None,
9008            created: metadata.created().ok(),
9009            readonly: metadata.permissions().readonly(),
9010            symlink_target: None,
9011            device: metadata.dev(),
9012            inode: metadata.ino(),
9013            mode: metadata.mode(),
9014            change_seconds: 0,
9015            change_nanoseconds: 0,
9016        })
9017    }
9018    #[cfg(not(unix))]
9019    {
9020        Ok(UntrackedFile {
9021            kind: 3,
9022            len: 0,
9023            modified: None,
9024            created: metadata.created().ok(),
9025            readonly: metadata.permissions().readonly(),
9026            symlink_target: None,
9027        })
9028    }
9029}
9030
9031fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
9032    let metadata = match std::fs::symlink_metadata(path) {
9033        Ok(metadata) => metadata,
9034        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
9035        Err(e) => {
9036            return Err(spar_err!(
9037                "could not inspect untracked file {}: {e}",
9038                path.display()
9039            ))
9040        }
9041    };
9042    let kind = if metadata.file_type().is_symlink() {
9043        2
9044    } else if metadata.is_file() {
9045        1
9046    } else {
9047        bail!(
9048            "untracked path {} is not a regular file or symlink",
9049            path.display()
9050        );
9051    };
9052    let symlink_target = if kind == 2 {
9053        match std::fs::read_link(path) {
9054            Ok(target) => Some(os_str_bytes(target.as_os_str())?),
9055            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
9056                return Ok(vanished_untracked_file())
9057            }
9058            Err(e) => {
9059                return Err(spar_err!(
9060                    "could not read untracked symlink {}: {e}",
9061                    path.display()
9062                ))
9063            }
9064        }
9065    } else {
9066        None
9067    };
9068    #[cfg(unix)]
9069    {
9070        use std::os::unix::fs::MetadataExt;
9071        Ok(UntrackedFile {
9072            kind,
9073            len: metadata.len(),
9074            modified: metadata.modified().ok(),
9075            created: metadata.created().ok(),
9076            readonly: metadata.permissions().readonly(),
9077            symlink_target,
9078            device: metadata.dev(),
9079            inode: metadata.ino(),
9080            mode: metadata.mode(),
9081            change_seconds: metadata.ctime(),
9082            change_nanoseconds: metadata.ctime_nsec(),
9083        })
9084    }
9085    #[cfg(not(unix))]
9086    {
9087        Ok(UntrackedFile {
9088            kind,
9089            len: metadata.len(),
9090            modified: metadata.modified().ok(),
9091            created: metadata.created().ok(),
9092            readonly: metadata.permissions().readonly(),
9093            symlink_target,
9094        })
9095    }
9096}
9097
9098#[cfg(unix)]
9099fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
9100    use std::os::unix::ffi::OsStrExt;
9101    Ok(value.as_bytes().to_vec())
9102}
9103
9104#[cfg(not(unix))]
9105fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
9106    value
9107        .to_str()
9108        .map(|value| value.as_bytes().to_vec())
9109        .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
9110}
9111
9112#[cfg(unix)]
9113fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
9114    use std::os::unix::fs::MetadataExt;
9115    right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
9116}
9117
9118#[cfg(not(unix))]
9119fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
9120    right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
9121}
9122
9123#[derive(Debug, Clone, serde::Serialize, Deserialize)]
9124pub struct BranchRecord {
9125    pub kind: String,
9126    pub number: i64,
9127}
9128
9129/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
9130/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
9131pub fn review_ref(number: i64) -> String {
9132    format!("refs/spar/pr-{number}")
9133}
9134
9135pub fn is_finished(state: &str) -> bool {
9136    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
9137}
9138
9139/// Write text through a temporary file and rename, so a kill cannot leave a
9140/// truncated file behind.
9141///
9142/// The follow-up queue is the one file spar rewrites in place rather than
9143/// appends to, and a truncated queue is lost work: what it held was never
9144/// written anywhere else.
9145pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
9146    if let Some(parent) = path.parent() {
9147        std::fs::create_dir_all(parent)
9148            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
9149    }
9150    // The extension defaults to `json` so `clear_state`, which removes a
9151    // leftover `pr-N.json.tmp` by name, keeps finding the one this wrote.
9152    let tmp = path.with_extension(format!(
9153        "{}.tmp",
9154        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
9155    ));
9156    std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
9157    std::fs::rename(&tmp, path)
9158        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
9159    Ok(())
9160}
9161
9162/// Whether a follow-up file already has an entry under this exact title.
9163///
9164/// Whole heading lines, case insensitively. A substring check answered yes
9165/// for "Retry loop" against "## Retry loop never terminates", which is a
9166/// different defect, and the new one was dropped with a line saying it was
9167/// already noted.
9168pub fn has_heading(text: &str, title: &str) -> bool {
9169    let wanted = title.trim().to_lowercase();
9170    if wanted.is_empty() {
9171        return false;
9172    }
9173    text.lines()
9174        .filter_map(|line| line.trim().strip_prefix("## "))
9175        .any(|heading| heading.trim().to_lowercase() == wanted)
9176}
9177
9178/// Write JSON through a temporary file and rename, so a kill cannot leave a
9179/// truncated state file behind.
9180pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
9181    write_text_atomic(path, &serde_json::to_string_pretty(value)?)
9182}
9183
9184/// One row of `gh pr list --json number,url,title,baseRefName,closingIssuesReferences`.
9185#[derive(Deserialize)]
9186#[serde(rename_all = "camelCase")]
9187pub struct LinkedPrRow {
9188    number: i64,
9189    #[serde(default)]
9190    url: String,
9191    #[serde(default)]
9192    title: String,
9193    #[serde(default)]
9194    closing_issues_references: Vec<IssueRef>,
9195}
9196
9197/// The listed pull requests, or `None` when the payload could not be read.
9198///
9199/// Separated from the gh call so the real payload shape can be tested, and kept
9200/// distinct from an empty list, because silently failing to parse would look
9201/// exactly like "no pull request exists", which is the answer that makes spar
9202/// implement over the top of somebody's work.
9203pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
9204    serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
9205}
9206
9207/// Among the listed pull requests, the first that would close `issue`.
9208pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
9209    rows.iter()
9210        .find(|row| {
9211            row.closing_issues_references
9212                .iter()
9213                .any(|linked| linked.number == issue)
9214        })
9215        .map(|row| PrRef {
9216            number: row.number,
9217            url: row.url.clone(),
9218            title: row.title.clone(),
9219        })
9220}
9221
9222/// Both halves together, for the tests that hold the real payload.
9223pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
9224    pick_linked_pr(&linked_pr_rows(json)?, issue)
9225}
9226
9227/// Flatten whatever `gh api --paginate` printed into a list of comments.
9228///
9229/// Current gh merges array pages into one array. Older builds concatenated one
9230/// document per page. A streaming parser reads either, and unlike splitting the
9231/// text on a bracket pair it cannot be fooled by a comment body that happens to
9232/// contain one, which would otherwise make a resume silently start over.
9233pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
9234    if text.trim().is_empty() {
9235        return Err(spar_err!("GitHub returned no comment data"));
9236    }
9237    let mut out = Vec::new();
9238    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
9239        match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
9240            Value::Array(items) => out.extend(items),
9241            _ => return Err(spar_err!("unexpected non-array comment page")),
9242        }
9243    }
9244    Ok(out)
9245}
9246
9247pub fn parse_comment_pages(text: &str) -> Vec<Value> {
9248    let mut out = Vec::new();
9249    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
9250        match value {
9251            Ok(Value::Array(items)) => out.extend(items),
9252            Ok(other) => out.push(other),
9253            Err(_) => break,
9254        }
9255    }
9256    out
9257}
9258
9259/// Whether a comment is a state block spar itself wrote.
9260///
9261/// The body has to open with the marker, so a person quoting it in a sentence
9262/// is not one; it has to parse, so a half-quoted block is not one; and it has
9263/// to be the viewer's own comment, the way `comments.rs` already tells spar's
9264/// writes apart. Anything less and `write_pr_state` patches somebody's comment
9265/// with JSON, and `spar clean --pr-state` later deletes it.
9266pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
9267    body.trim_start().starts_with(STATE_MARKER)
9268        && !viewer.trim().is_empty()
9269        && author.trim().eq_ignore_ascii_case(viewer.trim())
9270        && parse_state_comment(body).is_some()
9271}
9272
9273/// Extract the payload from a state comment. The marker is followed by JSON and
9274/// terminated with `-->`.
9275pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
9276    let marker = body.find(STATE_MARKER)?;
9277    let start = body[marker..].find('{')? + marker;
9278    let end = body.rfind('}')?;
9279    if end <= start {
9280        return None;
9281    }
9282    match serde_json::from_str(&body[start..=end]) {
9283        Ok(state) => Some(state),
9284        Err(_) => {
9285            logdim!("found a spar state comment but could not parse it");
9286            None
9287        }
9288    }
9289}
9290
9291fn choose_state_for_head(
9292    candidates: Vec<PersistedState>,
9293    actual_head: &str,
9294) -> Option<PersistedState> {
9295    let matching: Vec<PersistedState> = candidates
9296        .iter()
9297        .filter(|state| state.pr_head == actual_head)
9298        .cloned()
9299        .collect();
9300    if !matching.is_empty() {
9301        return newest_state(matching);
9302    }
9303    newest_state(candidates)
9304}
9305
9306fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
9307    candidates.into_iter().reduce(|best, candidate| {
9308        if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
9309            candidate
9310        } else {
9311            // The local candidate is supplied first. Keeping the first exact
9312            // tie recovers correctly from a local write followed by a failed
9313            // pull request state update, including legacy states with no
9314            // checkpoint field.
9315            best
9316        }
9317    })
9318}
9319
9320/// Where this binary lives, so `git filter-branch` can call back into it.
9321///
9322/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
9323/// tests, whose `current_exe` is the test harness rather than spar, and for
9324/// anyone who ships spar behind a wrapper script.
9325pub fn self_binary() -> Result<PathBuf> {
9326    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
9327        let path = PathBuf::from(path);
9328        if proc::is_executable(&path) {
9329            return Ok(path);
9330        }
9331        bail!(
9332            "SPAR_SELF_BIN is set to {}, which is not executable",
9333            path.display()
9334        );
9335    }
9336    std::env::current_exe()
9337        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
9338}
9339
9340fn bool_env(value: bool) -> &'static str {
9341    if value {
9342        "1"
9343    } else {
9344        "0"
9345    }
9346}
9347
9348/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
9349/// shell command, and an install path with a space in it is not exotic.
9350pub fn sh_quote(text: &str) -> String {
9351    format!("'{}'", text.replace('\'', r"'\''"))
9352}
9353
9354/// Style rules for the `scrub-filter` subcommand, which runs in a child process
9355/// spawned by git and so cannot see the parent's config.
9356pub fn style_from_env() -> Style {
9357    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
9358    Style {
9359        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
9360        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
9361        ..Style::permissive()
9362    }
9363}
9364
9365#[cfg(test)]
9366mod tests {
9367    use super::*;
9368    use crate::config::StateStore;
9369    use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
9370    use std::process::Command;
9371
9372    fn repo_for_titles() -> Repo {
9373        Repo {
9374            root: PathBuf::from("/nonexistent"),
9375            style: Style::default(),
9376            branch_prefix: String::new(),
9377            state_store: StateStore::Local,
9378            followups: crate::config::Followups::Issues,
9379            drafts: Drafts::Never,
9380            fork_when_denied: true,
9381            setup: CheckCommand::default(),
9382            isolate_reads: true,
9383            keep_worktrees: false,
9384            fork: OnceLock::new(),
9385            viewer: OnceLock::new(),
9386            checkpoints: Mutex::new(BTreeMap::new()),
9387            writes: WriteStats::default(),
9388        }
9389    }
9390
9391    /// The sweep deletes on what this says, so a name it does not recognise
9392    /// is no read checkout at all.
9393    #[test]
9394    fn only_a_read_checkout_name_names_its_owner() {
9395        assert_eq!(Some(4242), read_checkout_owner("read-4242-0"));
9396        assert_eq!(None, read_checkout_owner("read-4242"));
9397        assert_eq!(None, read_checkout_owner("read-me-0"));
9398        assert_eq!(None, read_checkout_owner("read-4242-x"));
9399        assert_eq!(None, read_checkout_owner("review-4242"));
9400    }
9401
9402    #[test]
9403    fn write_results_accumulate_for_the_run() {
9404        let repo = repo_for_titles();
9405
9406        let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
9407        let _: std::result::Result<(), ()> = repo.record_write(Err(()));
9408
9409        assert_eq!(
9410            WriteSummary {
9411                attempted: 2,
9412                failed: 1,
9413            },
9414            repo.write_summary()
9415        );
9416    }
9417
9418    #[test]
9419    fn only_failed_write_preflights_join_the_summary() {
9420        let repo = repo_for_titles();
9421
9422        let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
9423        let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
9424
9425        assert_eq!(
9426            WriteSummary {
9427                attempted: 1,
9428                failed: 1,
9429            },
9430            repo.write_summary()
9431        );
9432    }
9433
9434    #[test]
9435    fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
9436        let repo = repo_for_titles();
9437
9438        assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
9439        assert_eq!(
9440            WriteSummary {
9441                attempted: 1,
9442                failed: 1,
9443            },
9444            repo.write_summary()
9445        );
9446    }
9447
9448    #[test]
9449    fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
9450        let mut repo = repo_for_titles();
9451        repo.followups = Followups::Local;
9452
9453        assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
9454        assert_eq!(WriteSummary::default(), repo.write_summary());
9455    }
9456
9457    #[test]
9458    fn a_failed_remote_state_read_stops_before_state_mutation() {
9459        let root = std::env::temp_dir().join(format!(
9460            "spar-state-preflight-{}-{}",
9461            std::process::id(),
9462            std::time::SystemTime::now()
9463                .duration_since(std::time::UNIX_EPOCH)
9464                .unwrap()
9465                .as_nanos()
9466        ));
9467        std::fs::create_dir_all(&root).unwrap();
9468        let _fixture = ReviewFixture { root: root.clone() };
9469        let mut repo = repo_for_titles();
9470        repo.root = root;
9471        repo.state_store = StateStore::Both;
9472        let state = PersistedState {
9473            version: 1,
9474            checkpoint: 4,
9475            round: 2,
9476            next_actor: "a".into(),
9477            status: Status::Pending,
9478            pr_head: "abc123".into(),
9479            ledger: Ledger::new(),
9480            filed: Vec::new(),
9481            open_findings: Vec::new(),
9482            disputes: Vec::new(),
9483            noted: Vec::new(),
9484        };
9485
9486        let error = repo
9487            .write_state_after_remote_read(
9488                7,
9489                &state,
9490                Err(crate::error::SparError::new("state comments unavailable")),
9491            )
9492            .unwrap_err();
9493
9494        assert!(error.to_string().contains("state comments unavailable"));
9495        assert!(!repo.state_path(7).exists());
9496        assert_eq!(0, repo.remembered_checkpoint(7));
9497        assert_eq!(
9498            WriteSummary {
9499                attempted: 1,
9500                failed: 1,
9501            },
9502            repo.write_summary()
9503        );
9504    }
9505
9506    #[test]
9507    fn only_known_build_and_cache_directories_are_generated_artifacts() {
9508        assert!(is_generated_artifact(Path::new("target/debug/artifact")));
9509        assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
9510        assert!(is_generated_artifact(Path::new(
9511            "package/node_modules/dependency/file.js"
9512        )));
9513        assert!(!is_generated_artifact(Path::new(
9514            "distribution/required-package.js"
9515        )));
9516        assert!(!is_generated_artifact(Path::new(
9517            "generated/required-fixture.txt"
9518        )));
9519        assert!(!is_generated_artifact(Path::new("local.env")));
9520    }
9521
9522    #[test]
9523    fn only_finder_state_files_are_desktop_metadata() {
9524        assert!(is_desktop_metadata(Path::new(".DS_Store")));
9525        assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
9526        assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
9527        assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
9528    }
9529
9530    struct ReviewFixture {
9531        root: PathBuf,
9532    }
9533
9534    impl Drop for ReviewFixture {
9535        fn drop(&mut self) {
9536            let _ = std::fs::remove_dir_all(&self.root);
9537        }
9538    }
9539
9540    fn test_git(cwd: &Path, args: &[&str]) -> String {
9541        let output = Command::new("git")
9542            .args(args)
9543            .current_dir(cwd)
9544            .output()
9545            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
9546        assert!(
9547            output.status.success(),
9548            "git {args:?} failed: {}",
9549            String::from_utf8_lossy(&output.stderr)
9550        );
9551        String::from_utf8_lossy(&output.stdout).into_owned()
9552    }
9553
9554    /// The diff a line comment is checked against is parsed from git's output,
9555    /// so a user setting that changes that output must not change the answer,
9556    /// and a text converter the branch could have changed must never run.
9557    #[test]
9558    fn the_lines_a_comment_can_go_on_ignore_how_the_user_has_git_print_a_diff() {
9559        let root =
9560            std::env::temp_dir().join(format!("spar-repo-test-hunks-{}", std::process::id()));
9561        let _cleanup = ReviewFixture { root: root.clone() };
9562        let origin = root.join("origin.git");
9563        let work = root.join("work");
9564        let marker = root.join("textconv-ran");
9565        std::fs::create_dir_all(&origin).unwrap();
9566        std::fs::create_dir_all(&work).unwrap();
9567        test_git(&origin, &["init", "--bare", "-b", "main"]);
9568        test_git(&work, &["init", "-b", "main"]);
9569        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9570        test_git(&work, &["config", "user.name", "spar test"]);
9571        test_git(&work, &["config", "commit.gpgsign", "false"]);
9572        test_git(&work, &["config", "diff.noprefix", "true"]);
9573        test_git(&work, &["config", "diff.context", "0"]);
9574        test_git(&work, &["config", "diff.interHunkContext", "10"]);
9575        test_git(&work, &["config", "diff.algorithm", "patience"]);
9576        test_git(&work, &["config", "color.diff", "always"]);
9577        test_git(
9578            &work,
9579            &[
9580                "config",
9581                "diff.conv.textconv",
9582                &format!("touch {}; cat", marker.display()),
9583            ],
9584        );
9585        std::fs::write(work.join(".git/info/attributes"), "*.txt diff=conv\n").unwrap();
9586        let lines: String = (1..=20).map(|n| format!("line {n}\n")).collect();
9587        std::fs::write(work.join("src.txt"), &lines).unwrap();
9588        test_git(&work, &["add", "."]);
9589        test_git(&work, &["commit", "-m", "seed"]);
9590        test_git(
9591            &work,
9592            &["remote", "add", "origin", origin.to_str().unwrap()],
9593        );
9594        test_git(&work, &["push", "-u", "origin", "main"]);
9595        test_git(&work, &["checkout", "-b", "feature"]);
9596        std::fs::write(
9597            work.join("src.txt"),
9598            lines
9599                .replace("line 5\n", "changed 5\n")
9600                .replace("line 15\n", "changed 15\n"),
9601        )
9602        .unwrap();
9603        std::fs::write(work.join("new.txt"), "a\nb\n").unwrap();
9604        std::fs::write(work.join("with space.txt"), "a\n").unwrap();
9605        test_git(&work, &["add", "."]);
9606        test_git(&work, &["commit", "-m", "change"]);
9607        let cfg = crate::config::parse(
9608            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9609        )
9610        .unwrap();
9611        let repo = Repo::open(&work, &cfg).unwrap();
9612        assert_eq!(
9613            Hunks::from([
9614                ("new.txt".to_string(), vec![(1, 2)]),
9615                ("src.txt".to_string(), vec![(2, 8), (12, 18)]),
9616                ("with space.txt".to_string(), vec![(1, 1)]),
9617            ]),
9618            repo.diff_hunks(&work, "main")
9619        );
9620        assert!(!marker.exists(), "the text converter ran");
9621        // The converter is real: a plain diff with the same settings runs it.
9622        test_git(&work, &["diff", "origin/main...HEAD"]);
9623        assert!(marker.exists(), "the fixture's converter never runs at all");
9624    }
9625
9626    fn review_fixture(
9627        tag: &str,
9628        number: i64,
9629    ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
9630        use std::sync::atomic::{AtomicU32, Ordering};
9631        static NEXT: AtomicU32 = AtomicU32::new(0);
9632        let id = NEXT.fetch_add(1, Ordering::Relaxed);
9633        let root =
9634            std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
9635        let origin = root.join("origin.git");
9636        let work = root.join("work");
9637        std::fs::create_dir_all(&origin).unwrap();
9638        std::fs::create_dir_all(&work).unwrap();
9639        test_git(&origin, &["init", "--bare", "-b", "main"]);
9640        test_git(&work, &["init", "-b", "main"]);
9641        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9642        test_git(&work, &["config", "user.name", "spar test"]);
9643        test_git(&work, &["config", "commit.gpgsign", "false"]);
9644        test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
9645        test_git(&work, &["config", "filter.drop.smudge", "cat"]);
9646        std::fs::write(work.join("README.md"), "seed\n").unwrap();
9647        std::fs::write(work.join("data.txt"), "old\n").unwrap();
9648        std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
9649        std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
9650        test_git(&work, &["add", "."]);
9651        test_git(&work, &["commit", "-m", "seed"]);
9652        test_git(
9653            &work,
9654            &["remote", "add", "origin", origin.to_str().unwrap()],
9655        );
9656        test_git(&work, &["push", "-u", "origin", "main"]);
9657        test_git(
9658            &work,
9659            &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
9660        );
9661        let cfg = crate::config::parse(
9662            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9663        )
9664        .unwrap();
9665        let repo = Repo::open(&work, &cfg).unwrap();
9666        let path = repo.worktree_for_pr_head(number).unwrap();
9667        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9668        (ReviewFixture { root }, repo, path, checkpoint)
9669    }
9670
9671    #[test]
9672    fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
9673        let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
9674
9675        repo.release_review_worktree_checked(901, &checkpoint)
9676            .unwrap();
9677
9678        assert!(!path.exists());
9679    }
9680
9681    #[test]
9682    fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
9683        let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
9684        let work = fixture.root.join("work");
9685        let seed = test_git(&work, &["rev-parse", "HEAD"]);
9686        let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
9687        // Heads only the pull request holds, the second rewritten rather than
9688        // built on the first, as a rebase and force push leave them.
9689        let publish = |message: &str| {
9690            let oid = test_git(
9691                &work,
9692                &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
9693            );
9694            let oid = oid.trim().to_string();
9695            test_git(
9696                &work,
9697                &[
9698                    "push",
9699                    "--force",
9700                    "origin",
9701                    &format!("{oid}:refs/pull/923/head"),
9702                ],
9703            );
9704            oid
9705        };
9706        let reviewed = publish("reviewed head");
9707        repo.worktree_for_pr_head(923).unwrap();
9708        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
9709
9710        let rewritten = publish("rewritten head");
9711        repo.worktree_for_pr_head(923).unwrap();
9712
9713        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
9714    }
9715
9716    #[test]
9717    fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
9718        let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
9719        let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
9720        std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
9721        test_git(&path, &["add", "recovery.txt"]);
9722        test_git(&path, &["commit", "-m", "recovery commit"]);
9723        let recovery = test_git(&path, &["rev-parse", "HEAD"]);
9724        test_git(&path, &["reset", "--hard", "main"]);
9725
9726        assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
9727        test_git(
9728            &path,
9729            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9730        );
9731    }
9732
9733    #[test]
9734    fn a_review_ref_reflog_only_commit_prevents_deletion() {
9735        let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
9736        let local_ref = review_ref(921);
9737        let original = test_git(&path, &["rev-parse", &local_ref]);
9738        let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
9739        let recovery = test_git(
9740            &path,
9741            &[
9742                "commit-tree",
9743                tree.trim(),
9744                "-p",
9745                original.trim(),
9746                "-m",
9747                "review ref recovery",
9748            ],
9749        );
9750        test_git(
9751            &path,
9752            &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
9753        );
9754        test_git(
9755            &path,
9756            &["update-ref", &local_ref, original.trim(), recovery.trim()],
9757        );
9758
9759        assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
9760        assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
9761        test_git(
9762            &path,
9763            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9764        );
9765    }
9766
9767    #[test]
9768    fn an_unpublished_commit_message_draft_is_recoverable() {
9769        let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
9770        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9771        let git_dir = if raw.is_absolute() {
9772            raw
9773        } else {
9774            path.join(raw)
9775        };
9776        std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
9777
9778        assert!(repository_has_recoverable_work(&path, true).unwrap());
9779        assert_eq!(
9780            "unique recovery draft\n",
9781            std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
9782        );
9783    }
9784
9785    #[test]
9786    fn a_changed_review_worktree_is_retained_after_a_checked_read() {
9787        let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
9788        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9789
9790        let error = repo
9791            .release_review_worktree_checked(902, &checkpoint)
9792            .unwrap_err();
9793
9794        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9795        assert!(error.to_string().contains("kept for recovery"), "{error}");
9796        assert_eq!(
9797            "recover me\n",
9798            std::fs::read_to_string(path.join("README.md")).unwrap()
9799        );
9800        repo.release_review_worktree(902);
9801    }
9802
9803    #[test]
9804    fn a_review_commit_is_retained_after_a_checked_read() {
9805        let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
9806        std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
9807        test_git(&path, &["add", "review-note.txt"]);
9808        test_git(&path, &["commit", "-m", "local review recovery"]);
9809        let head = test_git(&path, &["rev-parse", "HEAD"]);
9810
9811        let error = repo
9812            .release_review_worktree_checked(903, &checkpoint)
9813            .unwrap_err();
9814
9815        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9816        assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
9817        assert_eq!(
9818            "recover me\n",
9819            std::fs::read_to_string(path.join("review-note.txt")).unwrap()
9820        );
9821        repo.release_review_worktree(903);
9822    }
9823
9824    /// The read is no longer what refuses: a file the call created and Git was
9825    /// told to ignore is its own output. Removal still refuses, so the file is
9826    /// there to look at, which is the part that mattered.
9827    #[test]
9828    fn an_ignored_review_file_is_retained_after_a_checked_read() {
9829        let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
9830        std::fs::create_dir_all(path.join("generated")).unwrap();
9831        std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
9832
9833        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9834            .expect("its own output is not a change");
9835        let error = repo
9836            .release_review_worktree_checked(904, &checkpoint)
9837            .unwrap_err();
9838
9839        assert!(error.to_string().contains("recoverable"), "{error}");
9840        assert_eq!(
9841            "recover me\n",
9842            std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
9843        );
9844        repo.release_review_worktree(904);
9845    }
9846
9847    #[test]
9848    /// The checkpoint no longer refuses this: a path the project ignores is out
9849    /// of scope for a call that only read. What keeps the file is the gate that
9850    /// actually deletes the checkout, which refuses any ignored file outside a
9851    /// known build directory. That is the check with the stake in it, and it is
9852    /// the one that has to hold.
9853    fn a_preexisting_ignored_review_file_change_is_retained() {
9854        let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
9855        std::fs::create_dir_all(path.join("generated")).unwrap();
9856        let ignored = path.join("generated/recovery.txt");
9857        std::fs::write(&ignored, "before\n").unwrap();
9858        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9859        std::fs::write(&ignored, "after!\n").unwrap();
9860
9861        let error = repo
9862            .release_review_worktree_checked(905, &checkpoint)
9863            .unwrap_err();
9864
9865        assert!(error.to_string().contains("recoverable"), "{error}");
9866        assert!(path.exists());
9867        assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
9868        repo.release_review_worktree(905);
9869    }
9870
9871    #[test]
9872    fn a_preexisting_ignored_review_file_prevents_checked_removal() {
9873        let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
9874        std::fs::create_dir_all(path.join("generated")).unwrap();
9875        let ignored = path.join("generated/recovery.txt");
9876        std::fs::write(&ignored, "keep me\n").unwrap();
9877        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9878
9879        let error = repo
9880            .release_review_worktree_checked(906, &checkpoint)
9881            .unwrap_err();
9882
9883        assert!(error.to_string().contains("recoverable"), "{error}");
9884        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
9885    }
9886
9887    #[test]
9888    fn overwriting_a_preexisting_untracked_file_is_detected() {
9889        let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
9890        let untracked = path.join("notes.txt");
9891        std::fs::write(&untracked, "before\n").unwrap();
9892        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9893        std::fs::write(&untracked, "after!\n").unwrap();
9894
9895        let error = repo
9896            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9897            .unwrap_err();
9898
9899        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9900        assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
9901    }
9902
9903    #[test]
9904    fn an_assume_unchanged_edit_is_detected() {
9905        let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
9906        test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
9907        std::fs::write(path.join("README.md"), "hidden\n").unwrap();
9908
9909        let error = repo
9910            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9911            .unwrap_err();
9912
9913        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9914        assert_eq!(
9915            "hidden\n",
9916            std::fs::read_to_string(path.join("README.md")).unwrap()
9917        );
9918    }
9919
9920    #[test]
9921    fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
9922        let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
9923        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9924        test_git(&path, &["add", "README.md"]);
9925        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9926
9927        let error = repo
9928            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9929            .unwrap_err();
9930
9931        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9932        assert_eq!(
9933            b"seed\r\n",
9934            std::fs::read(path.join("README.md")).unwrap().as_slice()
9935        );
9936    }
9937
9938    #[cfg(unix)]
9939    #[test]
9940    fn a_mode_edit_is_detected_when_filemode_is_disabled() {
9941        use std::os::unix::fs::PermissionsExt;
9942
9943        let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
9944        test_git(&path, &["config", "core.filemode", "false"]);
9945        let readme = path.join("README.md");
9946        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9947        permissions.set_mode(0o755);
9948        std::fs::set_permissions(&readme, permissions).unwrap();
9949        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9950
9951        let error = repo
9952            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9953            .unwrap_err();
9954
9955        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9956        assert_eq!(
9957            0o755,
9958            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9959        );
9960    }
9961
9962    #[test]
9963    fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
9964        let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
9965        std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
9966        test_git(&path, &["add", ".gitattributes"]);
9967        test_git(&path, &["commit", "-m", "select data filter"]);
9968        let baseline = repo.worktree_baseline(&path).unwrap();
9969        std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
9970
9971        assert!(repo
9972            .commit_pending_changes(&path, &baseline, "change data", "change data")
9973            .unwrap());
9974        let error = repo
9975            .refuse_unrepresented_tracked_changes(&path, &baseline)
9976            .unwrap_err();
9977
9978        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9979        assert_eq!(
9980            "secret: recover me\nnew\n",
9981            std::fs::read_to_string(path.join("data.txt")).unwrap()
9982        );
9983        assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
9984    }
9985
9986    #[test]
9987    fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
9988        let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
9989        std::fs::create_dir_all(path.join("target")).unwrap();
9990        let untracked = path.join("target/user.yaml");
9991        std::fs::write(&untracked, "user data\n").unwrap();
9992        let baseline = repo.worktree_baseline(&path).unwrap();
9993        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9994
9995        assert!(repo
9996            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9997            .unwrap());
9998
9999        assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
10000        assert_eq!(
10001            "?? target/user.yaml\n",
10002            test_git(&path, &["status", "--short", "--untracked-files=all"])
10003        );
10004        assert!(test_git(
10005            &path,
10006            &[
10007                "ls-tree",
10008                "-r",
10009                "--name-only",
10010                "HEAD",
10011                "--",
10012                "target/user.yaml"
10013            ]
10014        )
10015        .is_empty());
10016    }
10017
10018    #[test]
10019    fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
10020        let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
10021        std::fs::create_dir_all(path.join("target")).unwrap();
10022        let untracked = path.join("target/user.yaml");
10023        std::fs::write(&untracked, "before\n").unwrap();
10024        let baseline = repo.worktree_baseline(&path).unwrap();
10025        let before = test_git(&path, &["rev-parse", "HEAD"]);
10026        std::fs::write(&untracked, "after\n").unwrap();
10027        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10028
10029        let error = repo
10030            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10031            .unwrap_err();
10032
10033        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10034        assert!(error.to_string().contains("target/user.yaml"), "{error}");
10035        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
10036        assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
10037        assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
10038    }
10039
10040    #[test]
10041    fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
10042        let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
10043        let baseline = repo.worktree_baseline(&path).unwrap();
10044        std::fs::create_dir_all(path.join("target")).unwrap();
10045        std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
10046
10047        assert!(repo
10048            .commit_pending_changes(&path, &baseline, "add file", "add file")
10049            .unwrap());
10050
10051        assert_eq!(
10052            "new file\n",
10053            test_git(&path, &["show", "HEAD:target/new.txt"])
10054        );
10055        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10056    }
10057
10058    #[test]
10059    fn a_managed_commit_accepts_a_custom_output_rebuild() {
10060        let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
10061        exclude_paths(&repo, &["manager/public/"]);
10062        let output = path.join("manager/public/assets");
10063        std::fs::create_dir_all(&output).unwrap();
10064        std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
10065        std::fs::write(output.join("index.css"), "old styles\n").unwrap();
10066        let baseline = repo.worktree_baseline(&path).unwrap();
10067        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10068        std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
10069        std::fs::remove_file(output.join("old.js")).unwrap();
10070        std::fs::write(output.join("index.css"), "new styles\n").unwrap();
10071        std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
10072
10073        assert!(repo
10074            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10075            .unwrap());
10076        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10077        repo.refuse_changed_existing_untracked(&path, &baseline)
10078            .unwrap();
10079        repo.refuse_unrepresented_tracked_changes(&path, &baseline)
10080            .unwrap();
10081
10082        assert_eq!(
10083            "tracked change\n",
10084            test_git(&path, &["show", "HEAD:README.md"])
10085        );
10086        assert_eq!(
10087            "new source\n",
10088            test_git(&path, &["show", "HEAD:new-source.txt"])
10089        );
10090        assert!(test_git(
10091            &path,
10092            &[
10093                "ls-tree",
10094                "-r",
10095                "--name-only",
10096                "HEAD",
10097                "--",
10098                "manager/public"
10099            ]
10100        )
10101        .is_empty());
10102        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10103        assert!(!output.join("old.js").exists());
10104        assert_eq!(
10105            "new styles\n",
10106            std::fs::read_to_string(output.join("index.css")).unwrap()
10107        );
10108        assert!(repository_has_recoverable_work(&path, true).unwrap());
10109        repo.release_review_worktree(912);
10110        assert!(output.join("index-DUym-Rfj.js").exists());
10111    }
10112
10113    #[test]
10114    fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
10115        let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
10116        std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
10117        let baseline = repo.worktree_baseline(&path).unwrap();
10118        std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
10119        std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
10120
10121        let error = repo
10122            .commit_pending_changes(&path, &baseline, "change", "change")
10123            .unwrap_err();
10124
10125        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10126        assert!(error.to_string().contains("notes.txt"), "{error}");
10127    }
10128
10129    #[test]
10130    fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
10131        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
10132        let baseline = repo.worktree_baseline(&path).unwrap();
10133        let nested = path.join("generated/repro");
10134        std::fs::create_dir_all(&nested).unwrap();
10135        test_git(&nested, &["init"]);
10136        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10137
10138        assert!(repo
10139            .commit_pending_changes(&path, &baseline, "change", "change")
10140            .unwrap());
10141        assert!(!repo.has_uncommitted_changes(&path).unwrap());
10142        assert!(repository_has_recoverable_work(&path, true).unwrap());
10143        repo.release_review_worktree(927);
10144        assert!(nested.join(".git").exists());
10145    }
10146
10147    #[test]
10148    fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
10149        let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
10150        test_git(&path, &["config", "core.autocrlf", "true"]);
10151        std::fs::write(path.join("README.md"), "seed\n").unwrap();
10152        assert_eq!(
10153            test_git(&path, &["hash-object", "README.md"]).trim(),
10154            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
10155        );
10156
10157        assert!(repository_has_recoverable_work(&path, true).unwrap());
10158        assert_eq!(
10159            "seed\n",
10160            std::fs::read_to_string(path.join("README.md")).unwrap()
10161        );
10162    }
10163
10164    #[test]
10165    fn autocrlf_input_overrides_a_crlf_core_eol() {
10166        let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
10167        test_git(&path, &["config", "core.autocrlf", "input"]);
10168        test_git(&path, &["config", "core.eol", "crlf"]);
10169        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
10170        assert_eq!(
10171            test_git(&path, &["hash-object", "README.md"]).trim(),
10172            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
10173        );
10174
10175        assert!(repository_has_recoverable_work(&path, true).unwrap());
10176        assert_eq!(
10177            b"seed\r\n",
10178            std::fs::read(path.join("README.md")).unwrap().as_slice()
10179        );
10180    }
10181
10182    #[cfg(unix)]
10183    #[test]
10184    fn a_non_executable_permission_change_is_recoverable() {
10185        use std::os::unix::fs::PermissionsExt;
10186
10187        let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
10188        let readme = path.join("README.md");
10189        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
10190        permissions.set_mode(0o600);
10191        std::fs::set_permissions(&readme, permissions).unwrap();
10192        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10193
10194        let error = repo
10195            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10196            .unwrap_err();
10197
10198        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10199        assert!(repository_has_recoverable_work(&path, true).unwrap());
10200        assert_eq!(
10201            0o600,
10202            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
10203        );
10204    }
10205
10206    #[cfg(unix)]
10207    #[test]
10208    fn a_managed_commit_skips_signing_and_hooks() {
10209        use std::os::unix::fs::PermissionsExt;
10210
10211        let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
10212        let common = common_git_dir(&path).unwrap();
10213        let hook = common.join("hooks/pre-commit");
10214        let marker = fixture.root.join("hook-ran");
10215        std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
10216        std::fs::write(
10217            &hook,
10218            format!(
10219                "#!/bin/sh\nprintf ran > {}\nexit 1\n",
10220                sh_quote(marker.to_str().unwrap())
10221            ),
10222        )
10223        .unwrap();
10224        let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
10225        permissions.set_mode(0o755);
10226        std::fs::set_permissions(&hook, permissions).unwrap();
10227        test_git(&path, &["config", "commit.gpgsign", "true"]);
10228        test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
10229        std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
10230        test_git(&path, &["add", "managed.txt"]);
10231
10232        repo.commit_staged_changes(&path, "record managed change")
10233            .unwrap();
10234
10235        assert!(!marker.exists());
10236        assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
10237    }
10238
10239    #[test]
10240    fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
10241        let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
10242        std::fs::write(
10243            path.join(".gitattributes"),
10244            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
10245        )
10246        .unwrap();
10247        test_git(&path, &["add", ".gitattributes"]);
10248        test_git(&path, &["commit", "-m", "select automatic text"]);
10249        test_git(&path, &["config", "core.autocrlf", "true"]);
10250
10251        assert!(repository_has_recoverable_work(&path, true).unwrap());
10252    }
10253
10254    #[test]
10255    fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
10256        let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
10257        std::fs::write(
10258            path.join(".gitattributes"),
10259            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
10260        )
10261        .unwrap();
10262        test_git(&path, &["add", ".gitattributes"]);
10263        test_git(&path, &["commit", "-m", "select ident expansion"]);
10264        std::fs::write(path.join("README.md"), "seed\n").unwrap();
10265
10266        assert!(repository_has_recoverable_work(&path, true).unwrap());
10267    }
10268
10269    /// Add ignore rules the way SPAR does, without a tracked change the
10270    /// worktree would then be kept for.
10271    /// The failure this whole rule was rewritten for: a reviewer ran the
10272    /// project's binding generator to check a finding, and the lockfile it left
10273    /// beside its own manifest matched no directory name spar knew.
10274    #[test]
10275    fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
10276        let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
10277        exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
10278        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10279        std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
10280        std::fs::write(
10281            path.join("bindings/uniffi-bindgen/Cargo.lock"),
10282            "# generated\n",
10283        )
10284        .unwrap();
10285
10286        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10287            .expect("a path the project ignores is the call's own output");
10288    }
10289
10290    #[test]
10291    fn an_attribute_file_under_build_output_is_not_a_filter_change() {
10292        let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
10293        exclude_paths(&repo, &["target/"]);
10294        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10295        std::fs::create_dir_all(path.join("target/package")).unwrap();
10296        std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
10297
10298        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10299            .expect("an ignored attribute file governs only ignored paths");
10300        assert!(
10301            !repo.has_uncommitted_changes(&path).unwrap(),
10302            "ignored dependency attributes must not stop the next review"
10303        );
10304    }
10305
10306    #[test]
10307    fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
10308        let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
10309        std::fs::create_dir_all(path.join("source")).unwrap();
10310        std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
10311
10312        assert!(repo.has_uncommitted_changes(&path).unwrap());
10313    }
10314
10315    #[test]
10316    fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
10317        for source in ["repository", "nested", "local", "global"] {
10318            let (_fixture, repo, path, _initial) = review_fixture(source, 945);
10319            std::fs::create_dir_all(path.join("manager")).unwrap();
10320            match source {
10321                "repository" => {
10322                    std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
10323                        .unwrap();
10324                    test_git(&path, &["add", ".gitignore"]);
10325                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
10326                }
10327                "nested" => {
10328                    std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
10329                    test_git(&path, &["add", "manager/.gitignore"]);
10330                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
10331                }
10332                "local" => exclude_paths(&repo, &["manager/scratch/"]),
10333                "global" => {
10334                    let excludes = repo.root().parent().unwrap().join("global-ignore");
10335                    std::fs::write(&excludes, "manager/scratch/\n").unwrap();
10336                    test_git(
10337                        &path,
10338                        &["config", "core.excludesFile", excludes.to_str().unwrap()],
10339                    );
10340                }
10341                _ => unreachable!(),
10342            }
10343            let output = path.join("manager/scratch");
10344            std::fs::create_dir_all(&output).unwrap();
10345            std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
10346            std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
10347            let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10348            std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
10349            std::fs::remove_file(output.join(".DS_Store")).unwrap();
10350            for file in [
10351                ".gitattributes",
10352                "Cargo.lock",
10353                "font.woff2",
10354                "bundle.js",
10355                "cache.db",
10356            ] {
10357                std::fs::write(output.join(file), "generated\n").unwrap();
10358            }
10359            #[cfg(unix)]
10360            std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
10361            let nested = output.join("dependency");
10362            std::fs::create_dir_all(&nested).unwrap();
10363            test_git(&nested, &["init"]);
10364            repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10365                .unwrap_or_else(|error| panic!("{source}: {error}"));
10366            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10367
10368            let baseline = repo.worktree_baseline(&path).unwrap();
10369            std::fs::write(path.join("README.md"), "source fix\n").unwrap();
10370            std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
10371            std::fs::remove_file(output.join("Cargo.lock")).unwrap();
10372            std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
10373            std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
10374            assert!(repo
10375                .commit_pending_changes(&path, &baseline, "fix source", "fix source")
10376                .unwrap_or_else(|error| panic!("{source}: {error}")));
10377            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10378            assert!(test_git(
10379                &path,
10380                &[
10381                    "ls-tree",
10382                    "-r",
10383                    "--name-only",
10384                    "HEAD",
10385                    "--",
10386                    "manager/scratch"
10387                ]
10388            )
10389            .is_empty());
10390            assert!(
10391                repository_has_recoverable_work(&path, true).unwrap(),
10392                "{source}"
10393            );
10394            repo.release_review_worktree(945);
10395            assert_eq!(
10396                "LOCAL=updated\n",
10397                std::fs::read_to_string(output.join(".env.local")).unwrap()
10398            );
10399            assert!(nested.join(".git").exists(), "{source}");
10400        }
10401    }
10402
10403    /// Two reviewers share one checkout. The marker written when the first
10404    /// one's answer was refused used to be a brand new untracked file in the
10405    /// snapshot the second one was compared against.
10406    #[test]
10407    fn a_recovery_marker_is_not_a_change_to_the_worktree() {
10408        let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
10409        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10410        let _ = uncertain_worktree_change(&path, "an earlier call was refused");
10411
10412        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10413            .expect("spar's own marker is not something a call did");
10414    }
10415
10416    #[test]
10417    fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
10418        let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
10419
10420        let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
10421            .expect("a path a build removed is not an unreadable worktree");
10422
10423        assert_eq!(vanished_untracked_file(), gone);
10424    }
10425
10426    /// A path that goes is still a path that was there. Dropping it from the
10427    /// snapshot would let it pass for one the call never touched.
10428    #[test]
10429    fn a_vanished_path_is_not_recorded_as_absent() {
10430        let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
10431        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
10432        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10433        std::fs::remove_file(path.join("notes.txt")).unwrap();
10434
10435        let error = repo
10436            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10437            .unwrap_err();
10438
10439        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10440    }
10441
10442    fn exclude_paths(repo: &Repo, lines: &[&str]) {
10443        use std::io::Write;
10444        let path = repo.root().join(".git").join("info").join("exclude");
10445        let mut file = std::fs::OpenOptions::new()
10446            .create(true)
10447            .append(true)
10448            .open(&path)
10449            .unwrap();
10450        for line in lines {
10451            writeln!(file, "{line}").unwrap();
10452        }
10453    }
10454
10455    #[test]
10456    fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
10457        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
10458        exclude_paths(&repo, &["dist/"]);
10459        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10460        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10461        let baseline = repo.worktree_baseline(&path).unwrap();
10462        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10463        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10464        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10465        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10466
10467        assert!(repo
10468            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10469            .unwrap());
10470        assert_eq!(
10471            "tracked change\n",
10472            std::fs::read_to_string(path.join("README.md")).unwrap()
10473        );
10474    }
10475
10476    #[test]
10477    fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
10478        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
10479        exclude_paths(&repo, &["dist/"]);
10480        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10481        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10482        std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
10483        let baseline = repo.worktree_baseline(&path).unwrap();
10484        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10485        std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
10486        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10487        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10488
10489        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10490        repo.refuse_changed_existing_untracked(&path, &baseline)
10491            .unwrap();
10492    }
10493
10494    #[test]
10495    fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
10496        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
10497        exclude_paths(&repo, &["dist/"]);
10498        std::fs::create_dir_all(path.join("dist")).unwrap();
10499        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10500        let baseline = repo.worktree_baseline(&path).unwrap();
10501        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10502        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10503
10504        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10505        repo.refuse_changed_existing_untracked(&path, &baseline)
10506            .unwrap();
10507    }
10508
10509    /// The run this comes from: a reviewer rebuilt a project whose build output
10510    /// lands in `manager/public/assets/`, which no directory list will ever
10511    /// name. The guard inside the call kept the review. This check then called
10512    /// the same thirteen files unrepresentable and ended the round, five
10513    /// minutes of review thrown away over a path Git had been told to disown.
10514    /// The run this comes from: `gh pr merge --delete-branch` could not delete
10515    /// `issue-784` because `.spar-worktrees/issue-784`, left by the run that
10516    /// opened the pull request, still had it checked out.
10517    #[test]
10518    fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
10519        let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
10520        let held = repo.root().join(WORKTREE_DIR).join("issue-950");
10521        std::fs::create_dir_all(held.parent().unwrap()).unwrap();
10522        test_git(
10523            repo.root(),
10524            &[
10525                "worktree",
10526                "add",
10527                "-b",
10528                "issue-950",
10529                held.to_str().unwrap(),
10530                "main",
10531            ],
10532        );
10533
10534        let found = repo
10535            .worktree_holding("issue-950")
10536            .expect("the branch is checked out somewhere");
10537
10538        assert!(found.ends_with("issue-950"), "{}", found.display());
10539        assert_eq!(None, repo.worktree_holding("issue-951"));
10540    }
10541
10542    #[test]
10543    fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
10544        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
10545        exclude_paths(&repo, &["manager/public/assets/"]);
10546        std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
10547        let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
10548        std::fs::write(&bundle, "first build\n").unwrap();
10549        let baseline = repo.worktree_baseline(&path).unwrap();
10550        std::fs::write(&bundle, "second build\n").unwrap();
10551        std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
10552
10553        repo.refuse_read_only_leavings(&path, &baseline).unwrap();
10554        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10555    }
10556
10557    /// A repro test a reviewer left behind is moved out with its path kept,
10558    /// and the checkout matches its checkpoint again.
10559    #[test]
10560    fn a_file_a_reviewer_created_is_moved_aside_and_the_checkout_is_whole_again() {
10561        let (_fixture, repo, path, checkpoint) = review_fixture("scratch-aside", 945);
10562        std::fs::create_dir_all(path.join("tests")).unwrap();
10563        std::fs::write(path.join("tests/zz-repro.test.ts"), "repro\n").unwrap();
10564
10565        repo.set_aside_review_scratch(&path, &checkpoint, 945);
10566
10567        assert!(!path.join("tests/zz-repro.test.ts").exists());
10568        let shelves: Vec<PathBuf> = std::fs::read_dir(repo.root().join(".spar/scratch"))
10569            .unwrap()
10570            .flatten()
10571            .map(|entry| entry.path())
10572            .collect();
10573        assert_eq!(1, shelves.len(), "{shelves:?}");
10574        assert_eq!(
10575            "repro\n",
10576            std::fs::read_to_string(shelves[0].join("tests/zz-repro.test.ts")).unwrap()
10577        );
10578        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10579            .unwrap();
10580    }
10581
10582    /// Only what was not there before is moved. A rewritten tracked file, or
10583    /// an untracked one that was already there, stays put for the check to
10584    /// refuse.
10585    #[test]
10586    fn only_a_file_that_was_not_there_is_moved_aside() {
10587        let (_fixture, repo, path, _checkpoint) = review_fixture("scratch-only-new", 946);
10588        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
10589        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10590        std::fs::write(path.join("README.md"), "rewritten\n").unwrap();
10591        std::fs::write(path.join("notes.txt"), "after\n").unwrap();
10592
10593        repo.set_aside_review_scratch(&path, &checkpoint, 946);
10594
10595        assert_eq!(
10596            "rewritten\n",
10597            std::fs::read_to_string(path.join("README.md")).unwrap()
10598        );
10599        assert_eq!(
10600            "after\n",
10601            std::fs::read_to_string(path.join("notes.txt")).unwrap()
10602        );
10603        assert!(!repo.root().join(".spar/scratch").exists());
10604        let error = repo
10605            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10606            .unwrap_err();
10607        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10608        assert!(error.to_string().contains("README.md"), "{error}");
10609        assert!(error.to_string().contains("notes.txt"), "{error}");
10610    }
10611
10612    #[test]
10613    fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
10614        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
10615        exclude_paths(&repo, &["dist/"]);
10616        let baseline = repo.worktree_baseline(&path).unwrap();
10617        std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
10618
10619        let error = repo
10620            .refuse_read_only_leavings(&path, &baseline)
10621            .unwrap_err();
10622
10623        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10624        assert!(error.to_string().contains("scratch.md"), "{error}");
10625    }
10626
10627    #[test]
10628    fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
10629        let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
10630        exclude_paths(&repo, &["dist/", ".env.local"]);
10631        std::fs::create_dir_all(path.join("dist")).unwrap();
10632        let baseline = repo.worktree_baseline(&path).unwrap();
10633        std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
10634        std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
10635
10636        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10637        assert!(repository_has_recoverable_work(&path, true).unwrap());
10638    }
10639
10640    #[test]
10641    fn a_read_only_inspection_may_rebuild_generated_output() {
10642        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
10643        exclude_paths(&repo, &["dist/"]);
10644        std::fs::create_dir_all(path.join("dist")).unwrap();
10645        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10646        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10647        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10648        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10649
10650        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10651            .unwrap();
10652    }
10653
10654    #[test]
10655    fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
10656        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
10657        exclude_paths(&repo, &["dist/", ".env.local"]);
10658        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10659        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10660        std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
10661
10662        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10663            .unwrap();
10664    }
10665
10666    #[test]
10667    fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
10668        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
10669        exclude_paths(&repo, &["dist/", ".env.local"]);
10670        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10671        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10672        std::fs::remove_file(path.join(".env.local")).unwrap();
10673
10674        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10675            .unwrap();
10676    }
10677
10678    #[test]
10679    fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
10680        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
10681        exclude_paths(&repo, &["dist/"]);
10682        std::fs::write(path.join("notes.md"), "before\n").unwrap();
10683        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10684        std::fs::write(path.join("notes.md"), "after\n").unwrap();
10685
10686        let error = repo
10687            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10688            .unwrap_err();
10689
10690        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10691        assert!(error.to_string().contains("notes.md"), "{error}");
10692    }
10693
10694    #[test]
10695    fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
10696        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
10697        exclude_paths(&repo, &["dist/", "local.env"]);
10698        std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
10699        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10700        // Stop ignoring it, so the same bytes are now an ordinary untracked
10701        // file. What a path is counts, not only whether it moved.
10702        std::fs::write(
10703            repo.root().join(".git").join("info").join("exclude"),
10704            "dist/\n",
10705        )
10706        .unwrap();
10707
10708        let error = repo
10709            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10710            .unwrap_err();
10711
10712        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10713        assert!(error.to_string().contains("local.env"), "{error}");
10714    }
10715
10716    #[test]
10717    fn a_read_only_inspection_names_the_tracked_file_that_moved() {
10718        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
10719        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10720        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
10721
10722        let error = repo
10723            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10724            .unwrap_err();
10725
10726        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10727        assert!(error.to_string().contains("README.md"), "{error}");
10728    }
10729
10730    #[test]
10731    fn build_output_alone_does_not_keep_a_worktree() {
10732        let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
10733        exclude_paths(&repo, &["target/", "dist/"]);
10734        std::fs::create_dir_all(path.join("target/debug")).unwrap();
10735        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10736        std::fs::create_dir_all(path.join("dist/cli")).unwrap();
10737        std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
10738
10739        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10740        repo.release_review_worktree(933);
10741
10742        assert!(!path.exists());
10743    }
10744
10745    #[test]
10746    fn finder_metadata_alone_does_not_keep_a_worktree() {
10747        let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
10748        exclude_paths(&repo, &[".DS_Store"]);
10749        std::fs::create_dir_all(path.join("src")).unwrap();
10750        std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
10751        std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
10752
10753        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10754        repo.release_review_worktree(937);
10755
10756        assert!(!path.exists());
10757    }
10758
10759    #[test]
10760    fn a_fetch_record_alone_does_not_keep_a_worktree() {
10761        let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
10762        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
10763        let git_dir = if raw.is_absolute() {
10764            raw
10765        } else {
10766            path.join(raw)
10767        };
10768        let head = test_git(&path, &["rev-parse", "HEAD"]);
10769        std::fs::write(
10770            git_dir.join("FETCH_HEAD"),
10771            format!("{}\t\tbranch 'main' of origin\n", head.trim()),
10772        )
10773        .unwrap();
10774
10775        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10776        repo.release_review_worktree(938);
10777
10778        assert!(!path.exists());
10779    }
10780
10781    #[test]
10782    fn an_ignored_file_outside_build_output_keeps_a_worktree() {
10783        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
10784        exclude_paths(&repo, &["target/", ".env.local"]);
10785        std::fs::create_dir_all(path.join("target/debug")).unwrap();
10786        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10787        std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
10788
10789        assert!(repository_has_recoverable_work(&path, true).unwrap());
10790        repo.release_review_worktree(934);
10791
10792        assert_eq!(
10793            "TOKEN=keep me\n",
10794            std::fs::read_to_string(path.join(".env.local")).unwrap()
10795        );
10796    }
10797
10798    #[test]
10799    fn a_repository_nested_in_build_output_keeps_a_worktree() {
10800        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
10801        exclude_paths(&repo, &["node_modules/"]);
10802        let nested = path.join("node_modules/local-dep");
10803        std::fs::create_dir_all(&nested).unwrap();
10804        test_git(&nested, &["init"]);
10805        std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
10806
10807        assert!(repository_has_recoverable_work(&path, true).unwrap());
10808        repo.release_review_worktree(935);
10809
10810        assert!(nested.join(".git").exists());
10811    }
10812
10813    #[test]
10814    fn an_ordinary_untracked_file_keeps_a_worktree() {
10815        let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
10816        std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
10817
10818        assert!(repository_has_recoverable_work(&path, true).unwrap());
10819        repo.release_review_worktree(936);
10820
10821        assert_eq!(
10822            "somebody's notes\n",
10823            std::fs::read_to_string(path.join("notes.md")).unwrap()
10824        );
10825    }
10826
10827    #[test]
10828    fn a_legacy_crlf_checkout_is_retained_conservatively() {
10829        let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
10830        std::fs::write(
10831            path.join(".gitattributes"),
10832            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
10833        )
10834        .unwrap();
10835        test_git(&path, &["add", ".gitattributes"]);
10836        test_git(&path, &["commit", "-m", "select legacy line endings"]);
10837
10838        assert!(repository_has_recoverable_work(&path, true).unwrap());
10839    }
10840
10841    #[test]
10842    fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
10843        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
10844        let nested = path.join("tracked");
10845        std::fs::create_dir_all(&nested).unwrap();
10846        std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
10847        test_git(&path, &["add", "tracked/seed.txt"]);
10848        test_git(&path, &["commit", "-m", "add tracked directory"]);
10849        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10850        test_git(&nested, &["init"]);
10851
10852        let error = repo
10853            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10854            .unwrap_err();
10855
10856        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10857        assert!(error.to_string().contains("Git entry"), "{error}");
10858        assert!(nested.join(".git").exists());
10859    }
10860
10861    /// A reviewer that clones a reproduction into ignored build output used to
10862    /// lose the whole review. The state check refused every Git entry it had
10863    /// not put there, including the one Git reports as a nested checkout and
10864    /// the untracked snapshot already records by identity.
10865    #[test]
10866    fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
10867        let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
10868        exclude_paths(&repo, &["target/"]);
10869        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10870        let repro = path.join("target/pr918-repro");
10871        std::fs::create_dir_all(&repro).unwrap();
10872        test_git(&repro, &["init"]);
10873        std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
10874
10875        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10876            .unwrap();
10877
10878        assert!(repository_has_recoverable_work(&path, true).unwrap());
10879        assert!(repro.join(".git").exists());
10880    }
10881
10882    /// What sits under a nested checkout is that repository's business, so the
10883    /// scan stops at its directory rather than reading a checkout of its own
10884    /// as an entry the outer worktree has to account for.
10885    #[test]
10886    fn a_repository_inside_a_nested_checkout_is_left_alone() {
10887        let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
10888        exclude_paths(&repo, &["target/"]);
10889        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10890        let repro = path.join("target/repro");
10891        let vendored = repro.join("vendor/dep");
10892        std::fs::create_dir_all(&vendored).unwrap();
10893        test_git(&repro, &["init"]);
10894        test_git(&vendored, &["init"]);
10895
10896        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10897            .unwrap();
10898
10899        assert!(vendored.join(".git").exists());
10900    }
10901
10902    #[test]
10903    fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
10904        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
10905
10906        let state = ignored_untracked_state(repo.root()).unwrap();
10907
10908        let relative = path.strip_prefix(repo.root()).unwrap();
10909        assert!(
10910            state.files.contains_key(relative),
10911            "{:?}",
10912            state.files.keys().collect::<Vec<_>>()
10913        );
10914        assert!(state.is_ignored(relative));
10915    }
10916
10917    #[test]
10918    fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
10919        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
10920        let baseline = repo.worktree_baseline(repo.root()).unwrap();
10921        std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
10922        std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
10923
10924        repo.refuse_new_ignored_files(repo.root(), &baseline)
10925            .unwrap();
10926        repo.refuse_changed_existing_untracked(repo.root(), &baseline)
10927            .unwrap();
10928    }
10929
10930    #[test]
10931    fn deleting_a_resident_worktree_during_a_call_is_refused() {
10932        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
10933        let baseline = repo.worktree_baseline(repo.root()).unwrap();
10934        let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
10935        std::fs::remove_dir_all(&path).unwrap();
10936
10937        let read_error = repo
10938            .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
10939            .unwrap_err();
10940        assert!(
10941            read_error.to_string().contains("review-932"),
10942            "{read_error}"
10943        );
10944        let error = repo
10945            .refuse_new_ignored_files(repo.root(), &baseline)
10946            .unwrap_err();
10947
10948        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10949        assert!(error.to_string().contains("review-932"), "{error}");
10950    }
10951
10952    #[test]
10953    fn a_nested_repository_record_is_read_as_a_plain_path() {
10954        let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
10955        assert_eq!(Path::new("vendor/checkout"), path);
10956        assert!(nested);
10957
10958        let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
10959        assert_eq!(Path::new("vendor/notes.txt"), path);
10960        assert!(!nested);
10961
10962        assert!(untracked_record(b"/", "untracked").is_err());
10963    }
10964
10965    #[cfg(unix)]
10966    #[test]
10967    fn a_non_utf8_git_path_is_preserved_without_loss() {
10968        use std::os::unix::ffi::OsStrExt;
10969
10970        let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
10971
10972        assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
10973    }
10974
10975    #[test]
10976    fn guarded_merge_pins_the_reviewed_head() {
10977        let args = merge_pr_args("36", Some("abc123"), true);
10978        assert_eq!(
10979            vec![
10980                "pr",
10981                "merge",
10982                "36",
10983                "--squash",
10984                "--delete-branch",
10985                "--match-head-commit",
10986                "abc123"
10987            ],
10988            args
10989        );
10990    }
10991
10992    #[test]
10993    fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
10994        let pr = PrRef {
10995            number: 7,
10996            url: "https://example.test/pull/7".into(),
10997            title: "part one".into(),
10998        };
10999        let result = reconcile_pr_creation(
11000            "split-34-1",
11001            Err(crate::error::SparError::new("connection lost")),
11002            Ok(Some(pr)),
11003        )
11004        .unwrap();
11005        assert_eq!(7, result.number);
11006    }
11007
11008    #[test]
11009    fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
11010        let error = reconcile_pr_creation(
11011            "split-34-1",
11012            Err(crate::error::SparError::new("permission denied")),
11013            Ok(None),
11014        )
11015        .unwrap_err();
11016        assert!(error.to_string().contains("permission denied"), "{error}");
11017    }
11018
11019    #[test]
11020    fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
11021        let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
11022        assert!(pr_for_base(text, "split-34-2", "split-34-1")
11023            .unwrap()
11024            .is_none());
11025        let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
11026        assert_eq!(7, found.number);
11027    }
11028
11029    #[test]
11030    fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
11031        let result = reconcile_comment_post(
11032            34,
11033            "the summary",
11034            crate::error::SparError::new("connection lost"),
11035            Ok(vec![serde_json::json!({"body": "the summary"})]),
11036        );
11037        assert!(result.is_ok(), "{result:?}");
11038    }
11039
11040    #[test]
11041    fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
11042        let error = reconcile_comment_post(
11043            34,
11044            "the summary",
11045            crate::error::SparError::new("connection lost"),
11046            Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
11047        )
11048        .unwrap_err();
11049        assert_eq!("connection lost", error.to_string());
11050    }
11051
11052    #[test]
11053    fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
11054        let error = reconcile_comment_post(
11055            34,
11056            "the summary",
11057            crate::error::SparError::new("connection lost"),
11058            Err(crate::error::SparError::new("comments unavailable")),
11059        )
11060        .unwrap_err();
11061        assert!(
11062            error.to_string().contains("could not be verified"),
11063            "{error}"
11064        );
11065        assert!(
11066            error.to_string().contains("comments unavailable"),
11067            "{error}"
11068        );
11069        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11070        assert!(!error.worth_retrying());
11071    }
11072
11073    #[test]
11074    fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
11075        let result = reconcile_issue_edit(
11076            34,
11077            "wanted body",
11078            crate::error::SparError::new("connection lost"),
11079            Ok("wanted body".to_string()),
11080        );
11081        assert!(result.is_ok(), "{result:?}");
11082    }
11083
11084    #[test]
11085    fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
11086        let error = reconcile_issue_edit(
11087            34,
11088            "wanted body",
11089            crate::error::SparError::new("connection lost"),
11090            Err(crate::error::SparError::new("issue unavailable")),
11091        )
11092        .unwrap_err();
11093        assert!(
11094            error.to_string().contains("could not be verified"),
11095            "{error}"
11096        );
11097        assert!(error.to_string().contains("issue unavailable"), "{error}");
11098        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11099        assert!(!error.worth_retrying());
11100    }
11101
11102    #[test]
11103    fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
11104        let found = ExistingIssue {
11105            number: 101,
11106            url: "https://example.test/issues/101".into(),
11107            title: "child".into(),
11108            body: "body".into(),
11109            open: true,
11110        };
11111        let url = reconcile_issue_creation(
11112            "child",
11113            Err(crate::error::SparError::new("connection lost")),
11114            Ok(Some(found)),
11115        )
11116        .unwrap();
11117        assert_eq!("https://example.test/issues/101", url);
11118    }
11119
11120    #[test]
11121    fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
11122        let error = reconcile_issue_creation(
11123            "child",
11124            Err(crate::error::SparError::new("permission denied")),
11125            Ok(None),
11126        )
11127        .unwrap_err();
11128        assert!(error.to_string().contains("permission denied"), "{error}");
11129    }
11130
11131    #[test]
11132    fn an_unverifiable_issue_creation_is_marked_uncertain() {
11133        let error = reconcile_issue_creation(
11134            "child",
11135            Err(crate::error::SparError::new("connection lost")),
11136            Err(crate::error::SparError::new("issues unavailable")),
11137        )
11138        .unwrap_err();
11139        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11140        assert!(!error.worth_retrying());
11141    }
11142
11143    #[test]
11144    fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
11145        let result = reconcile_failed_split_push(
11146            "split-34-1",
11147            crate::error::SparError::new("connection lost"),
11148            Ok("abc123\n".into()),
11149            Ok("abc123\trefs/heads/split-34-1\n".into()),
11150        );
11151        assert!(result.is_ok(), "{result:?}");
11152    }
11153
11154    #[test]
11155    fn a_split_push_collision_is_definite_and_never_overwrites() {
11156        let error = reconcile_failed_split_push(
11157            "split-34-1",
11158            crate::error::SparError::new("lease rejected"),
11159            Ok("abc123\n".into()),
11160            Ok("def456\trefs/heads/split-34-1\n".into()),
11161        )
11162        .unwrap_err();
11163        assert!(!error.retain_worktree());
11164        assert!(
11165            error.to_string().contains("Nothing was overwritten"),
11166            "{error}"
11167        );
11168    }
11169
11170    #[test]
11171    fn an_unreadable_split_push_result_keeps_the_worktree() {
11172        let error = reconcile_failed_split_push(
11173            "split-34-1",
11174            crate::error::SparError::new("connection lost"),
11175            Ok("abc123\n".into()),
11176            Err(crate::error::SparError::new("origin unavailable")),
11177        )
11178        .unwrap_err();
11179        assert!(error.retain_worktree());
11180        assert!(error.to_string().contains("could not confirm"), "{error}");
11181    }
11182
11183    /// Follow-up deduplication compares a title it computed against the title
11184    /// GitHub stored. If those two transforms can disagree, the check never
11185    /// matches and every review round files another copy of the same issue.
11186    #[test]
11187    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
11188        let repo = repo_for_titles();
11189        for raw in [
11190            "Retry loop spins \u{2014} Retry-After parses to zero",
11191            "plain title",
11192            "  spread   over\nlines  ",
11193            "\u{1F916} Generated with something",
11194            &format!("a \u{2014} {}", "very long title ".repeat(20)),
11195            &"x".repeat(300),
11196            &format!("{} \u{2014} end", "y".repeat(88)),
11197            // Exactly the budget, with two spaceless dashes. The scrub turns
11198            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
11199            // character over budget per dash and a second pass clips again,
11200            // producing a different string. Scrub-then-clip cannot.
11201            &{
11202                let tail = "a\u{2014}b c\u{2014}d";
11203                let pad = Style::default().max_title_chars - tail.chars().count();
11204                format!("{}{tail}", "w".repeat(pad))
11205            },
11206        ] {
11207            let once = repo.clean_title(raw).unwrap();
11208            let twice = repo.clean_title(&once).unwrap();
11209            assert_eq!(once, twice, "not idempotent for {raw:?}");
11210            assert!(
11211                once.chars().count() <= repo.style.max_title_chars,
11212                "over budget: {once:?}"
11213            );
11214            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
11215        }
11216    }
11217
11218    #[test]
11219    fn a_title_with_an_em_dash_survives_as_readable_text() {
11220        let repo = repo_for_titles();
11221        assert_eq!(
11222            "Retry loop spins, Retry-After parses to zero",
11223            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
11224                .unwrap()
11225        );
11226    }
11227
11228    #[test]
11229    fn sh_quote_survives_a_quote() {
11230        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
11231    }
11232
11233    #[test]
11234    fn sh_quote_wraps_a_space() {
11235        assert_eq!(
11236            "'/Applications/My App/spar'",
11237            sh_quote("/Applications/My App/spar")
11238        );
11239    }
11240
11241    #[test]
11242    fn finished_states_are_recognised_case_insensitively() {
11243        assert!(is_finished("MERGED"));
11244        assert!(is_finished("closed"));
11245        assert!(!is_finished("OPEN"));
11246        assert!(!is_finished(""));
11247    }
11248
11249    fn state() -> PersistedState {
11250        PersistedState {
11251            version: 1,
11252            checkpoint: 0,
11253            round: 4,
11254            next_actor: "codex".into(),
11255            status: Status::Pending,
11256            pr_head: "abc123".into(),
11257            ledger: Ledger::new(),
11258            filed: vec![],
11259            open_findings: vec![Finding {
11260                severity: Severity::Blocking,
11261                title: "Unchecked error".into(),
11262                detail: "the failure is discarded".into(),
11263                file: "src/a.rs:12".into(),
11264                ..Finding::default()
11265            }],
11266            disputes: vec![Dispute {
11267                title: "Retry limit".into(),
11268                file: "src/net.rs".into(),
11269                reasoning: "the caller already bounds it".into(),
11270            }],
11271            noted: vec![Finding {
11272                severity: Severity::NonBlocking,
11273                title: "Timeout is fixed".into(),
11274                file: "src/config.rs".into(),
11275                ..Finding::default()
11276            }],
11277        }
11278    }
11279
11280    #[test]
11281    fn a_state_comment_round_trips() {
11282        let body = format!(
11283            "{STATE_MARKER}\n{}\n-->",
11284            serde_json::to_string(&state()).unwrap()
11285        );
11286        let back = parse_state_comment(&body).unwrap();
11287        assert_eq!(4, back.round);
11288        assert_eq!("codex", back.next_actor);
11289        assert_eq!("abc123", back.pr_head);
11290        assert_eq!("Unchecked error", back.open_findings[0].title);
11291        assert_eq!("src/net.rs", back.disputes[0].file);
11292        assert_eq!("Timeout is fixed", back.noted[0].title);
11293    }
11294
11295    #[test]
11296    fn old_state_without_new_lists_still_parses() {
11297        let body = format!(
11298            "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
11299             \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11300        );
11301        let back = parse_state_comment(&body).expect("old state");
11302        assert!(back.open_findings.is_empty());
11303        assert!(back.disputes.is_empty());
11304        assert!(back.noted.is_empty());
11305        assert!(back.pr_head.is_empty());
11306        assert_eq!(0, back.checkpoint);
11307    }
11308
11309    #[test]
11310    fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
11311        let mut local = state();
11312        local.pr_head = "old".into();
11313        local.round = 9;
11314        let mut remote = state();
11315        remote.pr_head = "current".into();
11316        remote.round = 4;
11317
11318        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11319        assert_eq!("current", chosen.pr_head);
11320        assert_eq!(4, chosen.round);
11321    }
11322
11323    #[test]
11324    fn checkpoint_order_breaks_same_round_ties() {
11325        let mut local = state();
11326        local.pr_head = "current".into();
11327        local.round = 4;
11328        local.checkpoint = 8;
11329        let mut remote = local.clone();
11330        remote.checkpoint = 7;
11331        remote.open_findings.clear();
11332
11333        let chosen = choose_state_for_head(vec![local], "current").unwrap();
11334        assert_eq!(8, chosen.checkpoint);
11335
11336        let mut local = state();
11337        local.pr_head = "current".into();
11338        local.round = 4;
11339        local.checkpoint = 8;
11340        let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
11341        assert_eq!(8, chosen.checkpoint);
11342    }
11343
11344    #[test]
11345    fn legacy_same_round_tie_keeps_the_local_checkpoint() {
11346        let mut local = state();
11347        local.pr_head = "current".into();
11348        local.round = 4;
11349        local.open_findings.push(Finding {
11350            title: "local checkpoint".into(),
11351            ..Finding::default()
11352        });
11353        let mut remote = state();
11354        remote.pr_head = "current".into();
11355        remote.round = 4;
11356
11357        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11358        assert_eq!(
11359            "local checkpoint",
11360            chosen.open_findings.last().unwrap().title
11361        );
11362    }
11363
11364    /// It must render as nothing, so PRs are not littered with machine state.
11365    #[test]
11366    fn the_state_block_is_an_html_comment() {
11367        let body = format!(
11368            "{STATE_MARKER}\n{}\n-->",
11369            serde_json::to_string(&state()).unwrap()
11370        );
11371        assert!(body.starts_with("<!--"));
11372        assert!(body.trim_end().ends_with("-->"));
11373        assert!(!body[..body.find('{').unwrap()].contains("-->"));
11374    }
11375
11376    #[test]
11377    fn an_unrelated_json_block_is_not_state() {
11378        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
11379    }
11380
11381    /// Patching and deleting a comment is not something to do on a substring.
11382    ///
11383    /// A maintainer who notices the block and asks about it writes a comment
11384    /// containing `spar:state`, and the loose filter could not tell theirs from
11385    /// spar's. The next `persist` replaced their words with JSON and
11386    /// `spar clean --pr-state` later deleted it.
11387    #[test]
11388    fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
11389        let block = format!(
11390            "{STATE_MARKER}\n{}\n-->",
11391            serde_json::to_string(&state()).unwrap()
11392        );
11393        assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
11394        assert!(
11395            is_state_comment(&block, "Spar-Bot", "spar-bot"),
11396            "a login differs only in case"
11397        );
11398
11399        assert!(
11400            !is_state_comment(
11401                "why is there a `<!-- spar:state` block on this PR?",
11402                "maintainer",
11403                "spar-bot"
11404            ),
11405            "a question about the block was treated as the block"
11406        );
11407        assert!(
11408            !is_state_comment(&block, "maintainer", "spar-bot"),
11409            "somebody else's comment is not spar's to rewrite"
11410        );
11411        assert!(
11412            !is_state_comment(
11413                &format!("here is what spar writes:\n\n{block}"),
11414                "spar-bot",
11415                "spar-bot"
11416            ),
11417            "the marker has to open the body, not appear in it"
11418        );
11419        assert!(
11420            !is_state_comment(
11421                &format!("{STATE_MARKER}\n{{not json\n-->"),
11422                "spar-bot",
11423                "spar-bot"
11424            ),
11425            "a block that does not parse is not state"
11426        );
11427        assert!(
11428            !is_state_comment(&block, "spar-bot", ""),
11429            "an unknown viewer proves nothing about who wrote it"
11430        );
11431    }
11432
11433    #[test]
11434    fn a_malformed_state_comment_is_none_not_a_panic() {
11435        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
11436    }
11437
11438    #[test]
11439    fn atomic_write_leaves_no_temp_file() {
11440        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
11441        let _ = std::fs::remove_dir_all(&dir);
11442        let path = dir.join("state").join("pr-7.json");
11443        write_json_atomic(&path, &state()).unwrap();
11444        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
11445            .unwrap()
11446            .flatten()
11447            .filter_map(|e| e.file_name().to_str().map(str::to_string))
11448            .collect();
11449        assert_eq!(vec!["pr-7.json".to_string()], files);
11450        let _ = std::fs::remove_dir_all(&dir);
11451    }
11452
11453    #[test]
11454    fn atomic_write_overwrites_rather_than_accumulating() {
11455        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
11456        let _ = std::fs::remove_dir_all(&dir);
11457        let path = dir.join("pr-7.json");
11458        for round in 1..4 {
11459            let mut s = state();
11460            s.round = round;
11461            write_json_atomic(&path, &s).unwrap();
11462        }
11463        let back: PersistedState =
11464            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11465        assert_eq!(3, back.round);
11466        let _ = std::fs::remove_dir_all(&dir);
11467    }
11468
11469    #[test]
11470    fn style_from_env_defaults_to_enforcing() {
11471        std::env::remove_var("SPAR_BAN_EM_DASH");
11472        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
11473        let style = style_from_env();
11474        assert!(style.ban_em_dash && style.ban_ai_attribution);
11475        assert!(
11476            !style.terse,
11477            "the commit filter must not truncate a commit message"
11478        );
11479    }
11480
11481    // -- publishing from a fork --------------------------------------------
11482
11483    /// GitHub's refusal is several lines of advice after the one that says
11484    /// what happened, and spar used to report only the last of them.
11485    #[test]
11486    fn a_push_denial_is_read_from_either_transport() {
11487        let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
11488                   fatal: Could not read from remote repository.\n\n\
11489                   Please make sure you have the correct access rights\n\
11490                   and the repository exists.";
11491        let https = "remote: Permission to example/project.git denied to someone.\n\
11492                     fatal: unable to access 'https://github.com/example/project.git/': The \
11493                     requested URL returned error: 403";
11494        let denied = Some("Permission to example/project.git denied to someone.");
11495        assert_eq!(denied, push_denial(ssh));
11496        assert_eq!(denied, push_denial(https));
11497
11498        let stale = "To github.com:example/project.git\n \
11499                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
11500                     error: failed to push some refs to 'github.com:example/project.git'";
11501        assert_eq!(None, push_denial(stale));
11502
11503        let err = push_error(
11504            &PushTarget::Origin,
11505            "issue-7",
11506            &crate::error::SparError::new(ssh),
11507        );
11508        assert_eq!(
11509            "could not push to origin/issue-7. Permission to example/project.git denied to \
11510             someone.",
11511            err.to_string()
11512        );
11513    }
11514
11515    /// An origin whose hook refuses every push with GitHub's wording, a bare
11516    /// repository standing in for the fork, and a checkout on a new branch
11517    /// with one commit to publish.
11518    #[cfg(unix)]
11519    fn denied_fixture(
11520        tag: &str,
11521        fork_when_denied: bool,
11522    ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
11523        use std::os::unix::fs::PermissionsExt;
11524        use std::sync::atomic::{AtomicU32, Ordering};
11525        static NEXT: AtomicU32 = AtomicU32::new(0);
11526        let id = NEXT.fetch_add(1, Ordering::Relaxed);
11527        let root =
11528            std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
11529        let origin = root.join("origin.git");
11530        let fork = root.join("fork.git");
11531        let work = root.join("work");
11532        for dir in [&origin, &fork, &work] {
11533            std::fs::create_dir_all(dir).unwrap();
11534        }
11535        test_git(&origin, &["init", "--bare", "-b", "main"]);
11536        test_git(&fork, &["init", "--bare", "-b", "main"]);
11537        test_git(&work, &["init", "-b", "main"]);
11538        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
11539        test_git(&work, &["config", "user.name", "spar test"]);
11540        test_git(&work, &["config", "commit.gpgsign", "false"]);
11541        std::fs::write(work.join("README.md"), "seed\n").unwrap();
11542        test_git(&work, &["add", "."]);
11543        test_git(&work, &["commit", "-m", "seed"]);
11544        test_git(
11545            &work,
11546            &["remote", "add", "origin", origin.to_str().unwrap()],
11547        );
11548        test_git(&work, &["push", "-q", "origin", "main"]);
11549        // Installed after the seed, so it is only spar's pushes it refuses.
11550        let hook = origin.join("hooks").join("pre-receive");
11551        std::fs::write(
11552            &hook,
11553            "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
11554        )
11555        .unwrap();
11556        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
11557        test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
11558        std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
11559        test_git(&work, &["add", "."]);
11560        test_git(&work, &["commit", "-m", "Fix it"]);
11561
11562        let mut text =
11563            String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
11564        if !fork_when_denied {
11565            text.push_str("[loop]\nfork_when_denied = false\n");
11566        }
11567        let cfg = crate::config::parse(&text).unwrap();
11568        let repo = Repo::open(&work, &cfg).unwrap();
11569        // What `fork` would have asked GitHub for, so no test reaches it.
11570        repo.fork
11571            .set(Fork {
11572                owner: "someone".into(),
11573                full_name: "someone/project".into(),
11574                url: fork.to_str().unwrap().into(),
11575            })
11576            .unwrap();
11577        (ReviewFixture { root }, repo, work, origin, fork)
11578    }
11579
11580    #[cfg(unix)]
11581    #[test]
11582    fn a_new_branch_origin_refuses_is_published_from_the_fork() {
11583        let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
11584        let first = test_git(&work, &["rev-parse", "HEAD"]);
11585
11586        let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11587
11588        assert_eq!("someone:issue-7", target.head("issue-7"));
11589        assert_eq!(
11590            first.trim(),
11591            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11592        );
11593        assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11594            .trim()
11595            .is_empty());
11596        assert_eq!(
11597            WriteSummary {
11598                attempted: 1,
11599                failed: 0
11600            },
11601            repo.write_summary(),
11602            "a refusal the fork answered is not a failed write"
11603        );
11604
11605        // A later round follows the branch to the fork, leasing what it
11606        // published there.
11607        std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
11608        test_git(&work, &["commit", "-qam", "Fix it again"]);
11609        repo.push_to(&work, &target, "issue-7", Some(first.trim()))
11610            .unwrap();
11611        let second = test_git(&work, &["rev-parse", "HEAD"]);
11612        assert_eq!(
11613            second.trim(),
11614            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11615        );
11616    }
11617
11618    /// What a closed pull request leaves on the fork: a branch with nothing
11619    /// a pull request does not hold. Origin's issue branches are rebuilt over
11620    /// in the same case.
11621    #[cfg(unix)]
11622    #[test]
11623    fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
11624        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
11625        test_git(
11626            &work,
11627            &[
11628                "push",
11629                "-q",
11630                fork.to_str().unwrap(),
11631                "main:refs/heads/issue-7",
11632            ],
11633        );
11634        let head = test_git(&work, &["rev-parse", "HEAD"]);
11635
11636        repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11637
11638        assert_eq!(
11639            head.trim(),
11640            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11641        );
11642        assert_eq!(0, repo.write_summary().failed);
11643    }
11644
11645    #[cfg(unix)]
11646    #[test]
11647    fn work_only_the_fork_has_is_not_published_over() {
11648        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
11649        test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
11650        std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
11651        test_git(&work, &["add", "."]);
11652        test_git(&work, &["commit", "-qm", "Somebody's work"]);
11653        test_git(
11654            &work,
11655            &[
11656                "push",
11657                "-q",
11658                fork.to_str().unwrap(),
11659                "elsewhere:refs/heads/issue-7",
11660            ],
11661        );
11662        let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
11663        test_git(&work, &["checkout", "-q", "issue-7"]);
11664
11665        let err = repo
11666            .publish_new_branch(&work, "issue-7", "main")
11667            .unwrap_err()
11668            .to_string();
11669
11670        assert!(
11671            err.contains(
11672                "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
11673            ),
11674            "{err}"
11675        );
11676        assert!(err.contains("--delete issue-7"), "{err}");
11677        assert_eq!(
11678            theirs.trim(),
11679            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11680        );
11681        assert_eq!(
11682            WriteSummary {
11683                attempted: 1,
11684                failed: 1
11685            },
11686            repo.write_summary()
11687        );
11688    }
11689
11690    /// A lease that loses says which ref and why, not git's closing line.
11691    #[test]
11692    fn a_rejected_push_names_the_ref_it_rejected() {
11693        let stale = "To github.com:example/project.git\n \
11694                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
11695                     error: failed to push some refs to 'github.com:example/project.git'";
11696
11697        let err = push_error(
11698            &PushTarget::Origin,
11699            "issue-7",
11700            &crate::error::SparError::new(stale),
11701        )
11702        .to_string();
11703
11704        assert!(
11705            err.starts_with(
11706                "could not push to origin/issue-7. [rejected]        HEAD -> issue-7 (stale info)"
11707            ),
11708            "{err}"
11709        );
11710    }
11711
11712    #[test]
11713    fn a_fork_is_reached_by_whatever_reaches_origin() {
11714        let cases = [
11715            (
11716                "git@github.com:example/project.git",
11717                Some("git@github.com:someone/project.git"),
11718            ),
11719            // No user: ~/.ssh/config supplies it.
11720            (
11721                "github.com:example/project.git",
11722                Some("github.com:someone/project.git"),
11723            ),
11724            // A host alias, how one machine pushes as two accounts.
11725            (
11726                "git@github-work:example/project.git",
11727                Some("git@github-work:someone/project.git"),
11728            ),
11729            // SSH over 443, where 22 is blocked.
11730            (
11731                "ssh://git@ssh.github.com:443/example/project.git",
11732                Some("ssh://git@ssh.github.com:443/someone/project.git"),
11733            ),
11734            (
11735                "https://github.com/example/project",
11736                Some("https://github.com/someone/project"),
11737            ),
11738            (
11739                "https://ghe.example.com/scm/example/project.git/",
11740                Some("https://ghe.example.com/scm/someone/project.git"),
11741            ),
11742            (
11743                "git@host:/srv/example/project.git",
11744                Some("git@host:/srv/someone/project.git"),
11745            ),
11746            ("/srv/git/project.git", None),
11747            ("https://github.com/project", None),
11748            ("", None),
11749        ];
11750        for (origin, want) in cases {
11751            assert_eq!(
11752                want.map(str::to_string),
11753                fork_url(origin, "someone/project"),
11754                "{origin}"
11755            );
11756        }
11757    }
11758
11759    #[test]
11760    fn a_fork_found_or_made_is_reached_the_way_origin_is() {
11761        let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
11762            "ssh_url":"git@github.com:someone/project.git",
11763            "clone_url":"https://github.com/someone/project.git","fork":true}"#;
11764        let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
11765            "sshUrl":"git@github.com:someone/project.git",
11766            "url":"https://github.com/someone/project"}]"#;
11767
11768        let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
11769        assert_eq!("git@github-work:someone/project.git", fork.url);
11770        assert_eq!("someone", fork.owner);
11771        assert_eq!("someone/project", fork.full_name);
11772        let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
11773            .unwrap()
11774            .unwrap();
11775        assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
11776        assert_eq!(
11777            None,
11778            fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
11779        );
11780
11781        // An origin with no owner/repo path falls back to GitHub's URL, the
11782        // SSH one when origin looks like SSH.
11783        let fork = fork_from_api(made, "https://github.com/project").unwrap();
11784        assert_eq!("https://github.com/someone/project.git", fork.url);
11785        let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
11786        assert_eq!("git@github.com:someone/project.git", fork.url);
11787
11788        assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
11789        assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
11790    }
11791
11792    #[test]
11793    fn only_the_viewers_own_fork_can_be_pushed_to() {
11794        let pr = |owner: &str, name: &str| PrView {
11795            number: 9,
11796            url: String::new(),
11797            title: String::new(),
11798            body: String::new(),
11799            head_ref_name: "issue-7".into(),
11800            base_ref_name: "main".into(),
11801            state: "OPEN".into(),
11802            closing_issues_references: Vec::new(),
11803            is_cross_repository: true,
11804            head_repository_owner: Some(crate::model::Author {
11805                login: owner.into(),
11806            }),
11807            head_repository: Some(crate::model::HeadRepository { name: name.into() }),
11808        };
11809        let origin = "git@github.com:example/project.git";
11810
11811        assert_eq!(
11812            Some(PushTarget::Fork(Fork {
11813                owner: "Someone".into(),
11814                full_name: "Someone/project".into(),
11815                url: "git@github.com:Someone/project.git".into(),
11816            })),
11817            own_fork_target(&pr("Someone", "project"), "someone", origin)
11818        );
11819        assert_eq!(
11820            None,
11821            own_fork_target(&pr("other", "project"), "someone", origin)
11822        );
11823        assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
11824        assert_eq!(
11825            None,
11826            own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
11827        );
11828        let mut unnamed = pr("someone", "project");
11829        unnamed.head_repository_owner = None;
11830        assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
11831    }
11832
11833    /// `spar resume` and `spar checkin` on a pull request from the user's own
11834    /// fork check its branch out from the fork and push back there.
11835    #[cfg(unix)]
11836    #[test]
11837    fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
11838        let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
11839        test_git(
11840            &work,
11841            &[
11842                "push",
11843                "-q",
11844                fork.to_str().unwrap(),
11845                "issue-7:refs/heads/issue-7",
11846            ],
11847        );
11848        let published = test_git(&work, &["rev-parse", "issue-7"]);
11849        test_git(&work, &["checkout", "-q", "main"]);
11850        let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
11851        let pr = PrView {
11852            number: 31,
11853            url: String::new(),
11854            title: String::new(),
11855            body: String::new(),
11856            head_ref_name: "issue-7".into(),
11857            base_ref_name: "main".into(),
11858            state: "OPEN".into(),
11859            closing_issues_references: Vec::new(),
11860            is_cross_repository: true,
11861            head_repository_owner: None,
11862            head_repository: None,
11863        };
11864
11865        let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
11866
11867        assert_eq!("issue-7", branch);
11868        assert_eq!(
11869            published.trim(),
11870            test_git(&path, &["rev-parse", "HEAD"]).trim()
11871        );
11872        std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
11873        test_git(&path, &["commit", "-qam", "Answer a comment"]);
11874        repo.push_to(&path, &target, &branch, Some(published.trim()))
11875            .unwrap();
11876        assert_eq!(
11877            test_git(&path, &["rev-parse", "HEAD"]).trim(),
11878            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11879        );
11880        assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11881            .trim()
11882            .is_empty());
11883    }
11884
11885    #[cfg(unix)]
11886    #[test]
11887    fn with_forking_off_a_refused_push_says_why_and_how() {
11888        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
11889
11890        let err = repo
11891            .publish_new_branch(&work, "issue-7", "main")
11892            .unwrap_err()
11893            .to_string();
11894
11895        assert!(
11896            err.contains("Permission to example/project.git denied to someone."),
11897            "{err}"
11898        );
11899        assert!(err.contains("fork_when_denied = true"), "{err}");
11900        assert!(test_git(&fork, &["branch", "--list", "issue-7"])
11901            .trim()
11902            .is_empty());
11903        assert_eq!(
11904            WriteSummary {
11905                attempted: 1,
11906                failed: 1
11907            },
11908            repo.write_summary()
11909        );
11910    }
11911}
11912
11913#[cfg(test)]
11914mod comment_page_tests {
11915    use super::*;
11916
11917    #[test]
11918    fn a_single_merged_array_is_read() {
11919        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
11920        assert_eq!(2, pages.len());
11921        assert_eq!(Some(2), pages[1]["id"].as_i64());
11922    }
11923
11924    #[test]
11925    fn concatenated_pages_from_an_older_gh_are_read_too() {
11926        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
11927        assert_eq!(2, pages.len());
11928    }
11929
11930    /// A comment body containing a bracket pair used to split the payload into
11931    /// two invalid halves, so no state comment was found and a resume silently
11932    /// started from round one.
11933    #[test]
11934    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
11935        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
11936        let pages = parse_comment_pages(text);
11937        assert_eq!(2, pages.len(), "{pages:?}");
11938        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
11939    }
11940
11941    #[test]
11942    fn empty_output_is_no_comments_not_a_panic() {
11943        assert!(parse_comment_pages("").is_empty());
11944        assert!(parse_comment_pages("   ").is_empty());
11945        assert!(parse_comment_pages("[]").is_empty());
11946    }
11947
11948    #[test]
11949    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
11950        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
11951    }
11952
11953    #[test]
11954    fn a_write_postcheck_rejects_truncated_comment_pages() {
11955        let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
11956        assert!(
11957            error.to_string().contains("unexpected comment pages"),
11958            "{error}"
11959        );
11960    }
11961
11962    #[test]
11963    fn a_write_postcheck_rejects_empty_or_non_array_output() {
11964        assert!(try_parse_comment_pages("").is_err());
11965        assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
11966        assert!(try_parse_comment_pages("[]").is_ok());
11967    }
11968
11969    #[test]
11970    fn state_is_found_in_the_last_matching_comment() {
11971        let payload = |round: u32| {
11972            format!(
11973                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11974            )
11975        };
11976        let text = serde_json::to_string(&serde_json::json!([
11977            {"id": 1, "body": payload(1)},
11978            {"id": 2, "body": "looks good to me"},
11979            {"id": 3, "body": payload(5)},
11980        ]))
11981        .unwrap();
11982        let pages = parse_comment_pages(&text);
11983        let last = pages
11984            .iter()
11985            .rev()
11986            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
11987            .unwrap();
11988        assert_eq!(5, last.round);
11989    }
11990}
11991
11992#[cfg(test)]
11993mod linked_pr_tests {
11994    use super::*;
11995
11996    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
11997    /// It carries an id and a whole repository object per linked issue, and a
11998    /// parser that chokes on those reports "no pull request", which is the one
11999    /// answer that makes spar implement over the top of somebody's work.
12000    const REAL_PAYLOAD: &str = r#"[
12001      {"number":14252,"title":"fix: reject leading-dash branch names",
12002       "url":"https://github.com/cli/cli/pull/14252",
12003       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
12004         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
12005         "url":"https://github.com/cli/cli/issues/14238"}]},
12006      {"number":14217,"title":"another change",
12007       "url":"https://github.com/cli/cli/pull/14217",
12008       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
12009         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
12010         "url":"https://github.com/cli/cli/issues/9761"}]},
12011      {"number":14200,"title":"unlinked work",
12012       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
12013    ]"#;
12014
12015    #[test]
12016    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
12017        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
12018        assert_eq!(14252, pr.number);
12019        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
12020    }
12021
12022    #[test]
12023    fn the_right_pr_is_picked_out_of_several() {
12024        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
12025    }
12026
12027    #[test]
12028    fn an_issue_nobody_is_working_on_finds_nothing() {
12029        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
12030    }
12031
12032    #[test]
12033    fn an_unlinked_pr_is_never_matched() {
12034        // 14200 closes nothing, so no issue number should ever return it.
12035        for issue in [14200, 0, 1] {
12036            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
12037                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
12038            }
12039        }
12040    }
12041
12042    #[test]
12043    fn empty_or_broken_output_is_none_rather_than_a_panic() {
12044        assert!(find_linked_pr("", 1).is_none());
12045        assert!(find_linked_pr("[]", 1).is_none());
12046        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
12047        assert!(find_linked_pr("[{\"number\":", 1).is_none());
12048    }
12049
12050    /// "gh could not answer" and "nobody is working on it" are different
12051    /// answers, and only one of them means implement from scratch.
12052    ///
12053    /// The parse used to fold both into `None`, so an authentication failure or
12054    /// a rate limit read as an empty list and spar opened a second pull request
12055    /// for an issue somebody already had work open on.
12056    #[test]
12057    fn an_unreadable_list_is_not_an_empty_list() {
12058        assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
12059        assert!(linked_pr_rows("[{\"number\":").is_none());
12060        assert!(linked_pr_rows("").is_none());
12061
12062        let empty = linked_pr_rows("[]").expect("an empty list is an answer");
12063        assert!(empty.is_empty());
12064        assert!(pick_linked_pr(&empty, 14238).is_none());
12065
12066        let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
12067        assert_eq!(3, listed.len());
12068        assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
12069    }
12070
12071    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
12072    #[test]
12073    fn pr_view_reads_the_cross_repository_flag() {
12074        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
12075                       "baseRefName":"main","state":"OPEN",
12076                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
12077        let pr: PrView = serde_json::from_str(json).unwrap();
12078        assert!(pr.is_cross_repository);
12079        assert!(pr.is_open());
12080
12081        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
12082        assert!(
12083            !serde_json::from_str::<PrView>(&same_repo)
12084                .unwrap()
12085                .is_cross_repository
12086        );
12087    }
12088}
12089
12090#[cfg(test)]
12091mod min_number_tests {
12092    /// The floor is applied before the cap, which is the order that matters.
12093    /// spar takes the *lowest* numbered open items, so a repository with a tail
12094    /// of old issues would otherwise spend its whole run in the tail: the cap
12095    /// would be filled by the oldest items and the floor would never be
12096    /// reached. Filtering first is what makes the setting do anything.
12097    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
12098        let mut numbers: Vec<i64> = open.to_vec();
12099        numbers.sort_unstable();
12100        if min_number > 0 {
12101            numbers.retain(|n| *n >= min_number);
12102        }
12103        numbers.truncate(limit);
12104        numbers
12105    }
12106
12107    #[test]
12108    fn the_floor_is_applied_before_the_cap_not_after() {
12109        let open = [12, 13, 14, 480, 481, 482];
12110        assert_eq!(vec![480, 481], pick(&open, 2, 480));
12111        // Capping first would have returned the two oldest and then filtered
12112        // them all away, leaving nothing.
12113        assert!(!pick(&open, 2, 480).is_empty());
12114    }
12115
12116    #[test]
12117    fn no_floor_keeps_the_old_behaviour() {
12118        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
12119    }
12120
12121    #[test]
12122    fn the_floor_is_inclusive() {
12123        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
12124    }
12125
12126    #[test]
12127    fn a_floor_above_everything_open_yields_nothing() {
12128        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
12129    }
12130}
12131
12132#[cfg(test)]
12133mod review_record_tests {
12134    use super::*;
12135    use serde_json::json;
12136
12137    #[test]
12138    fn prune_state_knows_which_files_belong_to_a_pull_request() {
12139        assert_eq!(Some(12), state_file_number("pr-12.json"));
12140        assert_eq!(Some(12), state_file_number("review-12.json"));
12141        assert_eq!(Some(12), state_file_number("checkin-12.json"));
12142        for shared in [
12143            "spend.json",
12144            "implementors.json",
12145            "contested.json",
12146            "listen.json",
12147            "pr-.json",
12148            "pr-12.json.tmp",
12149            "pr-x.json",
12150        ] {
12151            assert_eq!(None, state_file_number(shared), "{shared}");
12152        }
12153    }
12154
12155    #[test]
12156    fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
12157        let reviews = vec![
12158            json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
12159            json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
12160        ];
12161        assert!(has_viewer_review(&reviews, "me", &["the body"], "aaa"));
12162        assert!(!has_viewer_review(&reviews, "me", &["other"], "aaa"));
12163        assert!(!has_viewer_review(&reviews, "alice", &["the body"], "aaa"));
12164    }
12165
12166    /// A review with line comments goes up with a brief body, and the same
12167    /// review refused its lines goes up with the full one. A rerun after either
12168    /// landed would otherwise post a second review.
12169    #[test]
12170    fn a_review_that_landed_with_either_body_is_not_sent_again() {
12171        let brief = vec![
12172            json!({"user": {"login": "me"}, "body": "brief", "commit_id": "aaa", "state": "COMMENTED"}),
12173        ];
12174        let full = vec![
12175            json!({"user": {"login": "me"}, "body": "full", "commit_id": "aaa", "state": "CHANGES_REQUESTED"}),
12176        ];
12177        for landed in [&brief, &full] {
12178            assert!(has_viewer_review(landed, "me", &["brief", "full"], "aaa"));
12179            assert!(has_viewer_review(landed, "me", &["full", "brief"], "aaa"));
12180            assert!(!has_viewer_review(landed, "me", &["brief", "full"], "bbb"));
12181        }
12182        assert!(!has_viewer_review(&brief, "me", &["full"], "aaa"));
12183        assert!(!has_viewer_review(&full, "me", &[], "aaa"));
12184    }
12185
12186    /// A clean review says the same thing on every commit. Taking the approval
12187    /// of an older commit for this one left the new head unapproved while the
12188    /// record said it was.
12189    #[test]
12190    fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
12191        let older = vec![
12192            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
12193        ];
12194        assert!(!has_viewer_review(&older, "me", &["the body"], "bbb"));
12195        let dismissed = vec![
12196            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
12197        ];
12198        assert!(!has_viewer_review(&dismissed, "me", &["the body"], "bbb"));
12199    }
12200
12201    #[test]
12202    fn a_response_splits_into_status_headers_and_body() {
12203        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";
12204        let resp = parse_api_response(text).expect("a response");
12205        assert_eq!(200, resp.status);
12206        assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
12207        assert_eq!(
12208            Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
12209            resp.headers.get("last-modified")
12210        );
12211        assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
12212
12213        let not_modified =
12214            parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
12215        assert_eq!(304, not_modified.status);
12216        assert!(not_modified.body.is_empty());
12217
12218        assert_eq!(None, parse_api_response(""));
12219        assert_eq!(None, parse_api_response("gh: command not found\n"));
12220    }
12221
12222    #[test]
12223    fn review_requests_are_read_off_the_timeline_with_who_and_when() {
12224        let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
12225            {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
12226            {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
12227            {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
12228            {}
12229        ]}}}}})
12230        .to_string();
12231        let made = parse_review_requests(&text).expect("requests");
12232        assert_eq!(2, made.len());
12233        assert_eq!("2026-10-20T10:00:00Z", made[0].at);
12234        assert_eq!("viewer", made[0].who.login);
12235        assert!(made[0].who.slug.is_empty());
12236        assert_eq!("2026-10-20T11:00:00Z", made[1].at);
12237        assert!(made[1].who.login.is_empty());
12238        assert_eq!("example/ours", made[1].who.slug);
12239
12240        let none =
12241            json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
12242        assert_eq!(
12243            Some(0),
12244            parse_review_requests(&none.to_string()).map(|m| m.len())
12245        );
12246        assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
12247        assert!(parse_review_requests("gh: not json").is_none());
12248    }
12249
12250    #[test]
12251    fn assigned_issues_are_read_with_who_holds_them_and_when_each_was_assigned() {
12252        let text = json!({"data": {"repository": {"issues": {"nodes": [
12253            {
12254                "number": 1144,
12255                "title": "Send max",
12256                "assignees": {"nodes": [{"login": "viewer"}]},
12257                "timelineItems": {"nodes": [
12258                    {"createdAt": "2026-08-10T10:00:00Z", "assignee": {"login": "viewer"}},
12259                    {"createdAt": "2026-11-03T12:36:22Z", "assignee": {"login": "a-bot"}},
12260                    {"createdAt": "2026-11-03T12:40:00Z", "assignee": null},
12261                    {"assignee": {"login": "viewer"}},
12262                    {}
12263                ]}
12264            },
12265            {"title": "no number"},
12266            {"number": 7}
12267        ]}}}})
12268        .to_string();
12269        let issues = parse_assigned_issues(&text).expect("issues");
12270        assert_eq!(2, issues.len());
12271        assert_eq!(1144, issues[0].number);
12272        assert_eq!("Send max", issues[0].title);
12273        assert_eq!(vec!["viewer".to_string()], issues[0].assignees);
12274        assert_eq!(
12275            vec![
12276                ("2026-08-10T10:00:00Z".to_string(), "viewer".to_string()),
12277                ("2026-11-03T12:36:22Z".to_string(), "a-bot".to_string()),
12278            ],
12279            issues[0].assigned
12280        );
12281        assert_eq!(7, issues[1].number);
12282        assert!(issues[1].assignees.is_empty() && issues[1].assigned.is_empty());
12283
12284        let none = json!({"data": {"repository": {"issues": {"nodes": []}}}});
12285        assert_eq!(
12286            Some(0),
12287            parse_assigned_issues(&none.to_string()).map(|i| i.len())
12288        );
12289        assert!(parse_assigned_issues(r#"{"errors": [{"message": "no"}]}"#).is_none());
12290        assert!(parse_assigned_issues("gh: not json").is_none());
12291    }
12292
12293    #[test]
12294    fn one_issues_assignments_are_read_the_same_way() {
12295        let text = json!({"data": {"repository": {"issue": {
12296            "number": 42,
12297            "title": "t",
12298            "assignees": {"nodes": [{"login": "Viewer"}]},
12299            "timelineItems": {"nodes": [
12300                {"createdAt": "2026-11-03T12:36:22Z", "assignee": {"login": "Viewer"}}
12301            ]}
12302        }}}})
12303        .to_string();
12304        let issue = parse_issue_assignments(&text).expect("an issue");
12305        assert_eq!(42, issue.number);
12306        assert_eq!(
12307            Some("2026-11-03T12:36:22Z".to_string()),
12308            issue.newest_for("viewer")
12309        );
12310        assert!(parse_issue_assignments(r#"{"data": {"repository": {"issue": null}}}"#).is_none());
12311        assert!(parse_issue_assignments("gh: not json").is_none());
12312    }
12313
12314    #[test]
12315    fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
12316        let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
12317        let error = crate::error::SparError::call_failed("gh: boom");
12318        assert!(
12319            reconcile_review_post(5, &["the body"], "me", "aaa", error.clone(), Ok(landed)).is_ok()
12320        );
12321
12322        let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
12323        let err = reconcile_review_post(5, &["the body"], "me", "aaa", error.clone(), Ok(missing))
12324            .unwrap_err();
12325        assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
12326
12327        let unreadable = Err(crate::error::SparError::new("no network"));
12328        let err =
12329            reconcile_review_post(5, &["the body"], "me", "aaa", error, unreadable).unwrap_err();
12330        assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
12331        assert!(err.to_string().contains("could not be verified"), "{err}");
12332    }
12333    #[test]
12334    fn a_review_with_line_comments_names_the_new_side_and_a_range_only_when_it_has_one() {
12335        let comments = vec![
12336            InlineComment {
12337                path: "src/a.rs".into(),
12338                line: 12,
12339                start_line: None,
12340                body: "one".into(),
12341            },
12342            InlineComment {
12343                path: "src/b.rs".into(),
12344                line: 9,
12345                start_line: Some(4),
12346                body: "two".into(),
12347            },
12348        ];
12349        assert_eq!(
12350            json!({
12351                "commit_id": "abc",
12352                "event": "COMMENT",
12353                "body": "the body",
12354                "comments": [
12355                    {"path": "src/a.rs", "line": 12, "side": "RIGHT", "body": "one"},
12356                    {"path": "src/b.rs", "line": 9, "side": "RIGHT", "start_line": 4,
12357                     "start_side": "RIGHT", "body": "two"},
12358                ],
12359            }),
12360            review_payload("abc", "COMMENT", "the body", &comments)
12361        );
12362    }
12363
12364    #[test]
12365    fn the_new_side_of_each_hunk_is_read_and_its_lines_are_never_taken_for_headers() {
12366        let diff = r#"diff --git a/src/a.rs b/src/a.rs
12367index 1111111..2222222 100644
12368--- a/src/a.rs
12369+++ b/src/a.rs
12370@@ -1,3 +1,4 @@ fn x
12371 one
12372+++ an added line that looks like a header
12373 two
12374 three
12375@@ -20 +21,2 @@
12376-old
12377+new
12378+more
12379@@ -30,1 +32,0 @@
12380-gone
12381diff --git a/gone.rs b/gone.rs
12382deleted file mode 100644
12383--- a/gone.rs
12384+++ /dev/null
12385@@ -1,2 +0,0 @@
12386-a
12387-b
12388diff --git a/old.rs b/renamed.rs
12389similarity index 90%
12390rename from old.rs
12391rename to renamed.rs
12392--- a/old.rs
12393+++ b/renamed.rs
12394@@ -5,0 +6,1 @@
12395+inserted
12396diff --git a/with space.rs b/with space.rs
12397--- a/with space.rs	
12398+++ b/with space.rs	
12399@@ -2 +2 @@
12400-x
12401+y
12402diff --git "a/sp\tace.rs" "b/sp\tace.rs"
12403--- "a/sp\tace.rs"
12404+++ "b/sp\tace.rs"
12405@@ -1 +1 @@
12406-x
12407+y
12408"#;
12409        let hunks = new_side_hunks(diff);
12410        assert_eq!(
12411            Hunks::from([
12412                ("renamed.rs".to_string(), vec![(6, 6)]),
12413                ("src/a.rs".to_string(), vec![(1, 4), (21, 22)]),
12414                ("with space.rs".to_string(), vec![(2, 2)]),
12415            ]),
12416            hunks
12417        );
12418    }
12419
12420    #[test]
12421    fn only_a_refusal_is_final_enough_to_send_a_review_again() {
12422        let error = |text: &str| crate::error::SparError::new(text);
12423        assert!(refused(&error(
12424            "command failed (1): gh api\ngh: Validation Failed (HTTP 422)"
12425        )));
12426        assert!(!refused(&error("gh: Bad Gateway (HTTP 502)")));
12427        assert!(!refused(&error("gh: HTTP 504: Gateway Timeout")));
12428        assert!(!refused(&error("command timed out after 300s")));
12429    }
12430
12431    #[test]
12432    fn a_hunk_header_with_its_counts_left_out_counts_one() {
12433        assert_eq!(Some(((20, 1), (21, 2))), hunk_header("@@ -20 +21,2 @@"));
12434        assert_eq!(Some(((1, 3), (1, 1))), hunk_header("@@ -1,3 +1 @@ fn x"));
12435        assert_eq!(None, hunk_header("@@@ -1,3 -1,3 +1,4 @@@"));
12436        assert_eq!(None, hunk_header(" @@ -1 +1 @@"));
12437    }
12438}