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
954/// How long to wait before each further try of a push the remote failed on
955/// its own side. Two more tries over about forty seconds rides out the blips
956/// GitHub has, and a remote that stays down longer is better reported than
957/// waited on.
958const PUSH_RETRY_WAITS: [std::time::Duration; 2] = [
959    std::time::Duration::from_secs(10),
960    std::time::Duration::from_secs(30),
961];
962
963/// Reasons a remote gives for turning a ref down that are about the remote,
964/// not the push. GitHub answers `fatal error in commit_refs_rules` for a
965/// branch no rule applies to, and takes the same push a minute later.
966const REMOTE_FAULTS: &[&str] = &["fatal error", "internal server error", "internal error"];
967
968/// What git says when the connection, not the remote's judgement, failed.
969const CONNECTION_FAULTS: &[&str] = &[
970    "the remote end hung up unexpectedly",
971    "rpc failed",
972    "returned error: 5",
973    "connection reset",
974    "connection timed out",
975    "operation timed out",
976    "connection closed by",
977    "could not resolve host",
978    "early eof",
979    "unexpected disconnect",
980    "kex_exchange_identification",
981    "ssl_error_syscall",
982    "gnutls_handshake",
983    "http/2 stream",
984];
985
986/// The line of a failed push that says the remote or the connection failed
987/// on its own side, so the same push could go through if tried again.
988///
989/// Anything that is about the push itself is left out, because the remote
990/// would refuse the same push again: a denial, a lease that lost, a hook or
991/// ruleset that declined it, or a request the server will not take, such as
992/// one too large (HTTP 413, which git follows with a hang up).
993fn transient_push_failure(message: &str) -> Option<&str> {
994    if push_denial(message).is_some() {
995        return None;
996    }
997    let lower = message.to_ascii_lowercase();
998    if lower.contains("returned error: 4") || lower.contains("rpc failed; http 4") {
999        return None;
1000    }
1001    if let Some(rejection) = push_rejection(message) {
1002        let reason = rejection.to_ascii_lowercase();
1003        let internal = rejection.starts_with("[remote rejected]")
1004            && REMOTE_FAULTS.iter().any(|fault| reason.contains(fault));
1005        return internal.then_some(rejection);
1006    }
1007    message.lines().map(str::trim).find(|line| {
1008        let line = line.to_ascii_lowercase();
1009        CONNECTION_FAULTS.iter().any(|fault| line.contains(fault))
1010    })
1011}
1012
1013/// Push, and push again after a wait when the remote fails on its own side.
1014///
1015/// `landed` rereads the remote before each further try and after the last
1016/// one. A push whose answer was lost on the way back may have gone through,
1017/// and pushing it again would then lose its lease to itself. `sleep` returns
1018/// true when the process was told to stop while it waited, which ends the
1019/// retries with the failure in hand.
1020fn retry_transient_push(
1021    label: &str,
1022    mut push: impl FnMut() -> Result<()>,
1023    mut landed: impl FnMut() -> bool,
1024    waits: &[std::time::Duration],
1025    mut sleep: impl FnMut(std::time::Duration) -> bool,
1026) -> Result<()> {
1027    let mut waits = waits.iter();
1028    loop {
1029        let error = match push() {
1030            Ok(()) => return Ok(()),
1031            Err(error) => error,
1032        };
1033        if !error.worth_retrying() {
1034            return Err(error);
1035        }
1036        let message = error.to_string();
1037        let Some(fault) = transient_push_failure(&message) else {
1038            return Err(error);
1039        };
1040        if landed() {
1041            log!("the push to {label} reported a failure but reached the remote, so it stands.");
1042            return Ok(());
1043        }
1044        let Some(wait) = waits.next() else {
1045            return Err(error);
1046        };
1047        log!(
1048            "the push to {label} failed on the remote's side: {fault}\nPushing again in {}s.",
1049            wait.as_secs()
1050        );
1051        if sleep(*wait) {
1052            return Err(error);
1053        }
1054    }
1055}
1056
1057fn push_error(
1058    target: &PushTarget,
1059    branch: &str,
1060    e: &crate::error::SparError,
1061) -> crate::error::SparError {
1062    let message = e.to_string();
1063    if let Some(denied) = push_denial(&message) {
1064        return spar_err!("could not push to {}. {denied}", target.describe(branch));
1065    }
1066    if let Some(fault) = transient_push_failure(&message) {
1067        return e.with_message(format!(
1068            "could not push to {}. {fault}\nThe remote failed on its own side rather than \
1069             refusing the branch, and pushing again did not get through. Try again later.",
1070            target.describe(branch)
1071        ));
1072    }
1073    spar_err!(
1074        "could not push to {}. {}\nCheck push access and whether the branch moved under you.",
1075        target.describe(branch),
1076        push_rejection(&message).unwrap_or(e.last_line())
1077    )
1078}
1079
1080/// The fork's URL, reached the way origin is: origin's push URL with only its
1081/// `owner/repo` path replaced.
1082///
1083/// Keeping everything but the path is what keeps an SSH host alias, which is
1084/// how one machine pushes as two GitHub accounts, a port, such as SSH over
1085/// 443 where 22 is blocked, and an scp-style address with no user in it.
1086/// GitHub's own URLs for the fork would drop all three. None for anything
1087/// that is not a URL or an scp-style address with an `owner/repo` path.
1088fn fork_url(origin_url: &str, full_name: &str) -> Option<String> {
1089    let origin = origin_url.trim();
1090    let path_start = match origin.find("://") {
1091        Some(scheme) => {
1092            let host = scheme + 3;
1093            host + origin[host..].find('/')? + 1
1094        }
1095        None => {
1096            // scp-style is `[user@]host:path`, and git reads a colon after a
1097            // slash as part of a local path instead.
1098            let colon = origin.find(':')?;
1099            if origin[..colon].contains('/') {
1100                return None;
1101            }
1102            colon + 1
1103        }
1104    };
1105    let path = origin[path_start..].trim_end_matches('/');
1106    let (path, suffix) = match path.strip_suffix(".git") {
1107        Some(bare) => (bare, ".git"),
1108        None => (path, ""),
1109    };
1110    let segments: Vec<&str> = path.split('/').collect();
1111    if segments.len() < 2 || segments[segments.len() - 2..].iter().any(|s| s.is_empty()) {
1112        return None;
1113    }
1114    let prefix = &segments[..segments.len() - 2];
1115    let prefix = if prefix.is_empty() {
1116        String::new()
1117    } else {
1118        format!("{}/", prefix.join("/"))
1119    };
1120    Some(format!(
1121        "{}{prefix}{full_name}{suffix}",
1122        &origin[..path_start]
1123    ))
1124}
1125
1126/// A fork from what GitHub said about it, reached the way origin is.
1127///
1128/// GitHub's own URL is the fallback for an origin `fork_url` cannot read, the
1129/// SSH one when origin looks like SSH.
1130fn fork_reached_like_origin(
1131    owner: String,
1132    full_name: String,
1133    origin_url: &str,
1134    ssh_url: String,
1135    https_url: String,
1136) -> Fork {
1137    let origin = origin_url.trim();
1138    let url = fork_url(origin, &full_name).unwrap_or_else(|| {
1139        if origin.starts_with("git@") || origin.starts_with("ssh://") {
1140            ssh_url
1141        } else {
1142            https_url
1143        }
1144    });
1145    Fork {
1146        owner,
1147        full_name,
1148        url,
1149    }
1150}
1151
1152#[derive(Deserialize)]
1153struct ForkOwner {
1154    login: String,
1155}
1156
1157/// The fork GitHub made, or already had, in answer to a request to fork.
1158fn fork_from_api(answer: &str, origin_url: &str) -> Result<Fork> {
1159    #[derive(Deserialize)]
1160    struct Answer {
1161        full_name: String,
1162        owner: ForkOwner,
1163        ssh_url: String,
1164        clone_url: String,
1165    }
1166
1167    let answer: Answer = serde_json::from_str(answer.trim())
1168        .map_err(|e| spar_err!("unexpected answer from GitHub when forking: {e}"))?;
1169    Ok(fork_reached_like_origin(
1170        answer.owner.login,
1171        answer.full_name,
1172        origin_url,
1173        answer.ssh_url,
1174        answer.clone_url,
1175    ))
1176}
1177
1178/// The authenticated user's fork from a listing of this repository's forks
1179/// that only the user owns, or None when there is no such fork.
1180fn fork_from_listing(answer: &str, origin_url: &str) -> Result<Option<Fork>> {
1181    #[derive(Deserialize)]
1182    #[serde(rename_all = "camelCase")]
1183    struct Node {
1184        name_with_owner: String,
1185        owner: ForkOwner,
1186        ssh_url: String,
1187        url: String,
1188    }
1189
1190    let nodes: Vec<Node> = serde_json::from_str(answer.trim())
1191        .map_err(|e| spar_err!("unexpected answer from GitHub listing forks: {e}"))?;
1192    Ok(nodes.into_iter().next().map(|node| {
1193        fork_reached_like_origin(
1194            node.owner.login,
1195            node.name_with_owner,
1196            origin_url,
1197            node.ssh_url,
1198            node.url,
1199        )
1200    }))
1201}
1202
1203/// Where spar can push a pull request from a fork: the fork, when it is the
1204/// authenticated user's own, and nowhere when it is anybody else's.
1205fn own_fork_target(pr: &PrView, viewer: &str, origin_url: &str) -> Option<PushTarget> {
1206    let owner = pr.head_repository_owner.as_ref()?.login.trim();
1207    let name = pr.head_repository.as_ref()?.name.trim();
1208    if owner.is_empty() || name.is_empty() || !owner.eq_ignore_ascii_case(viewer.trim()) {
1209        return None;
1210    }
1211    let full_name = format!("{owner}/{name}");
1212    Some(PushTarget::Fork(Fork {
1213        owner: owner.to_string(),
1214        url: fork_url(origin_url, &full_name)?,
1215        full_name,
1216    }))
1217}
1218
1219fn reconcile_pr_creation(
1220    branch: &str,
1221    created: Result<String>,
1222    found: Result<Option<PrRef>>,
1223) -> Result<PrRef> {
1224    match (created, found) {
1225        (_, Ok(Some(pr))) => Ok(pr),
1226        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1227            "PR creation reported success but none was found for {branch}"
1228        ))),
1229        (Err(create), Ok(None)) => Err(spar_err!(
1230            "could not open a PR for {branch}. {}",
1231            create.last_line()
1232        )),
1233        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1234            "PR creation reported success for {branch}, but it could not be verified. {}",
1235            check.last_line()
1236        ))),
1237        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1238            "could not open a PR for {branch}. {} The result could not be verified: {}",
1239            create.last_line(),
1240            check.last_line()
1241        ))),
1242    }
1243}
1244
1245fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
1246    #[derive(Deserialize)]
1247    #[serde(rename_all = "camelCase")]
1248    struct Row {
1249        number: i64,
1250        #[serde(default)]
1251        url: String,
1252        #[serde(default)]
1253        title: String,
1254        base_ref_name: String,
1255    }
1256
1257    let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
1258        spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
1259    })?;
1260    Ok(rows
1261        .into_iter()
1262        .find(|row| row.base_ref_name == base)
1263        .map(|row| PrRef {
1264            number: row.number,
1265            url: row.url,
1266            title: row.title,
1267        }))
1268}
1269
1270/// Sign a comment spar composed, so it can be told from one the same account
1271/// wrote by hand. Idempotent: a body already signed is returned as it is.
1272fn signed(body: &str) -> String {
1273    if body.contains(COMMENT_MARKER) {
1274        return body.to_string();
1275    }
1276    format!("{body}\n\n{COMMENT_MARKER}")
1277}
1278
1279/// Whether a comment body is one spar composed rather than one a person wrote.
1280pub fn is_spar_comment(body: &str) -> bool {
1281    body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
1282}
1283
1284/// What `gh api -i` printed, taken apart.
1285#[derive(Debug, Clone, Default, PartialEq, Eq)]
1286pub struct ApiResponse {
1287    pub status: u16,
1288    /// Header names lowercased, values trimmed.
1289    pub headers: BTreeMap<String, String>,
1290    pub body: String,
1291}
1292
1293/// Split `gh api -i` output into the status, the headers, and the body. None
1294/// when it does not start with a status line, which is what a spawn that
1295/// printed an error looks like.
1296pub fn parse_api_response(stdout: &str) -> Option<ApiResponse> {
1297    let mut lines = stdout.split_inclusive('\n');
1298    let first = lines.next()?.trim_end_matches(['\r', '\n']);
1299    if !first.starts_with("HTTP/") {
1300        return None;
1301    }
1302    let status: u16 = first.split_whitespace().nth(1)?.parse().ok()?;
1303    let mut headers = BTreeMap::new();
1304    let mut body = String::new();
1305    let mut in_body = false;
1306    for line in lines {
1307        if in_body {
1308            body.push_str(line);
1309            continue;
1310        }
1311        let bare = line.trim_end_matches(['\r', '\n']);
1312        if bare.is_empty() {
1313            in_body = true;
1314            continue;
1315        }
1316        if let Some((name, value)) = bare.split_once(':') {
1317            headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
1318        }
1319    }
1320    Some(ApiResponse {
1321        status,
1322        headers,
1323        body,
1324    })
1325}
1326
1327/// The review requests on a pull request's timeline. The last hundred are
1328/// plenty: only the newest one that asks the viewer matters.
1329const REQUESTS_QUERY: &str = "\
1330query($owner: String!, $repo: String!, $number: Int!) {
1331  repository(owner: $owner, name: $repo) {
1332    pullRequest(number: $number) {
1333      timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], last: 100) {
1334        nodes {
1335          ... on ReviewRequestedEvent {
1336            createdAt
1337            requestedReviewer {
1338              ... on User { login }
1339              ... on Team { combinedSlug }
1340            }
1341          }
1342        }
1343      }
1344    }
1345  }
1346}";
1347
1348/// The requests `REQUESTS_QUERY` returned, oldest first. None when the answer
1349/// is not that shape. A request for somebody GitHub no longer knows names
1350/// nobody and is left out, or it would read as a team nobody could check.
1351pub fn parse_review_requests(text: &str) -> Option<Vec<crate::model::ReviewRequested>> {
1352    let v: Value = serde_json::from_str(text).ok()?;
1353    let nodes = v
1354        .pointer("/data/repository/pullRequest/timelineItems/nodes")?
1355        .as_array()?;
1356    let text_at = |node: &Value, path: &str| {
1357        node.pointer(path)
1358            .and_then(Value::as_str)
1359            .unwrap_or_default()
1360            .to_string()
1361    };
1362    Some(
1363        nodes
1364            .iter()
1365            .map(|node| crate::model::ReviewRequested {
1366                at: text_at(node, "/createdAt"),
1367                who: crate::model::Login {
1368                    login: text_at(node, "/requestedReviewer/login"),
1369                    slug: text_at(node, "/requestedReviewer/combinedSlug"),
1370                },
1371            })
1372            .filter(|r| !r.at.is_empty() && !(r.who.login.is_empty() && r.who.slug.is_empty()))
1373            .collect(),
1374    )
1375}
1376
1377/// What one issue says about who it is assigned to, as both assignment
1378/// queries ask for it. The last twenty assignments are plenty: only the newest
1379/// one of the viewer matters.
1380const ASSIGNMENT_FIELDS: &str = "
1381        number
1382        title
1383        assignees(first: 100) { nodes { login } }
1384        timelineItems(itemTypes: [ASSIGNED_EVENT], last: 20) {
1385          nodes {
1386            ... on AssignedEvent {
1387              createdAt
1388              assignee {
1389                ... on User { login }
1390                ... on Bot { login }
1391                ... on Mannequin { login }
1392              }
1393            }
1394          }
1395        }";
1396
1397/// The open issues assigned to one login, most recently updated first. A
1398/// hundred is far more than anybody is assigned at once, and the newest
1399/// updates, which are where a new assignment shows, come first.
1400fn assigned_issues_query() -> String {
1401    format!(
1402        "query($owner: String!, $repo: String!, $login: String!) {{
1403  repository(owner: $owner, name: $repo) {{
1404    issues(states: OPEN, filterBy: {{assignee: $login}}, first: 100, orderBy: {{field: UPDATED_AT, direction: DESC}}) {{
1405      nodes {{{ASSIGNMENT_FIELDS}
1406      }}
1407    }}
1408  }}
1409}}"
1410    )
1411}
1412
1413/// The same for one issue, open or not.
1414fn issue_assignments_query() -> String {
1415    format!(
1416        "query($owner: String!, $repo: String!, $number: Int!) {{
1417  repository(owner: $owner, name: $repo) {{
1418    issue(number: $number) {{{ASSIGNMENT_FIELDS}
1419    }}
1420  }}
1421}}"
1422    )
1423}
1424
1425/// One issue node either query returned. None without a number.
1426fn assigned_issue(node: &Value) -> Option<crate::model::AssignedIssue> {
1427    let text_at = |node: &Value, path: &str| {
1428        node.pointer(path)
1429            .and_then(Value::as_str)
1430            .unwrap_or_default()
1431            .to_string()
1432    };
1433    let nodes = |path: &str| {
1434        node.pointer(path)
1435            .and_then(Value::as_array)
1436            .cloned()
1437            .unwrap_or_default()
1438    };
1439    Some(crate::model::AssignedIssue {
1440        number: node.pointer("/number")?.as_i64()?,
1441        title: text_at(node, "/title"),
1442        assignees: nodes("/assignees/nodes")
1443            .iter()
1444            .map(|a| text_at(a, "/login"))
1445            .filter(|login| !login.is_empty())
1446            .collect(),
1447        // An assignment of somebody GitHub no longer knows names nobody, and
1448        // one without a time cannot be compared with anything.
1449        assigned: nodes("/timelineItems/nodes")
1450            .iter()
1451            .map(|e| (text_at(e, "/createdAt"), text_at(e, "/assignee/login")))
1452            .filter(|(at, who)| !at.is_empty() && !who.is_empty())
1453            .collect(),
1454    })
1455}
1456
1457/// The issues `assigned_issues_query` returned. None when the answer is not
1458/// that shape, which is a failed sweep rather than nobody assigned anything.
1459pub fn parse_assigned_issues(text: &str) -> Option<Vec<crate::model::AssignedIssue>> {
1460    let v: Value = serde_json::from_str(text).ok()?;
1461    let nodes = v.pointer("/data/repository/issues/nodes")?.as_array()?;
1462    Some(nodes.iter().filter_map(assigned_issue).collect())
1463}
1464
1465/// The issue `issue_assignments_query` returned. None when the answer is not
1466/// that shape.
1467pub fn parse_issue_assignments(text: &str) -> Option<crate::model::AssignedIssue> {
1468    let v: Value = serde_json::from_str(text).ok()?;
1469    assigned_issue(v.pointer("/data/repository/issue")?)
1470}
1471
1472/// The pull request a per item state file is about: `pr-12.json`,
1473/// `review-12.json`, `checkin-12.json`. None for anything else, so the shared
1474/// files beside them, `spend.json` and the rest, are never touched.
1475pub(crate) fn state_file_number(name: &str) -> Option<i64> {
1476    let stem = name.strip_suffix(".json")?;
1477    ["pr-", "review-", "checkin-"]
1478        .iter()
1479        .find_map(|prefix| stem.strip_prefix(prefix))
1480        .filter(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
1481        .and_then(|digits| digits.parse().ok())
1482}
1483
1484/// Whether the viewer has already submitted this review: exactly one of these
1485/// bodies, on this commit, and still standing. A clean review reads the same on
1486/// every commit, so a body alone would take an approval of an older commit for
1487/// this one; and a dismissed review was withdrawn, so it is not this one either.
1488///
1489/// More than one body because a review with line comments goes up with a
1490/// shorter one than the same review without them.
1491fn has_viewer_review(reviews: &[Value], viewer: &str, bodies: &[&str], commit: &str) -> bool {
1492    viewer_review(reviews, viewer, bodies, commit).is_some()
1493}
1494
1495/// The review `has_viewer_review` finds.
1496fn viewer_review<'a>(
1497    reviews: &'a [Value],
1498    viewer: &str,
1499    bodies: &[&str],
1500    commit: &str,
1501) -> Option<&'a Value> {
1502    reviews.iter().find(|review| {
1503        let field = |name: &str| review.get(name).and_then(Value::as_str).unwrap_or("");
1504        let standing = !matches!(
1505            field("state").to_uppercase().as_str(),
1506            "DISMISSED" | "PENDING"
1507        );
1508        review
1509            .get("user")
1510            .and_then(|u| u.get("login"))
1511            .and_then(Value::as_str)
1512            .is_some_and(|login| crate::comments::same_login(login, viewer))
1513            && bodies.contains(&field("body"))
1514            && field("commit_id").eq_ignore_ascii_case(commit)
1515            && standing
1516    })
1517}
1518
1519fn reconcile_review_post(
1520    number: i64,
1521    bodies: &[&str],
1522    viewer: &str,
1523    commit: &str,
1524    post_error: crate::error::SparError,
1525    reviews: Result<Vec<Value>>,
1526) -> Result<Option<String>> {
1527    match reviews {
1528        Ok(reviews) => match viewer_review(&reviews, viewer, bodies, commit) {
1529            Some(review) => Ok(html_url(review)),
1530            None => Err(post_error),
1531        },
1532        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1533            "could not submit a review on #{number}. {} The result could not be verified: {}",
1534            post_error.last_line(),
1535            read_error.last_line()
1536        ))),
1537    }
1538}
1539
1540/// The body of a review that carries line comments, for `gh api --input -`.
1541///
1542/// Every comment is on the new side of the diff, which is the only side a
1543/// finding about the branch is ever about.
1544fn review_payload(commit: &str, event: &str, body: &str, comments: &[InlineComment]) -> Value {
1545    let comments: Vec<Value> = comments
1546        .iter()
1547        .map(|c| {
1548            let mut row = serde_json::json!({
1549                "path": c.path,
1550                "line": c.line,
1551                "side": "RIGHT",
1552                "body": c.body,
1553            });
1554            if let Some(start) = c.start_line {
1555                row["start_line"] = serde_json::json!(start);
1556                row["start_side"] = serde_json::json!("RIGHT");
1557            }
1558            row
1559        })
1560        .collect();
1561    serde_json::json!({
1562        "commit_id": commit,
1563        "event": event,
1564        "body": body,
1565        "comments": comments,
1566    })
1567}
1568
1569/// Whether GitHub looked at a write and refused it, rather than failing to
1570/// answer. A refusal is final, so the same write changed is safe to send; a
1571/// timeout or a gateway error says nothing about whether the first one landed.
1572fn refused(e: &crate::error::SparError) -> bool {
1573    e.to_string().contains("(HTTP 422)")
1574}
1575
1576/// The lines of each file a pull request's diff shows on its new side, as
1577/// first and last line of each hunk. GitHub takes a line comment only on one
1578/// of these, and refuses the whole review over one that is not.
1579pub type Hunks = BTreeMap<String, Vec<(u32, u32)>>;
1580
1581/// Read the hunks out of a unified diff.
1582///
1583/// A hunk's lines are counted off against its header rather than recognised by
1584/// their first character, so an added line that itself begins `+++ ` or `@@ `
1585/// is content and not the start of something. A path git had to quote is left
1586/// out: what is between the quotes is escaped, not a path GitHub would match.
1587fn new_side_hunks(diff: &str) -> Hunks {
1588    let mut out = Hunks::new();
1589    let mut path: Option<String> = None;
1590    let (mut old_left, mut new_left) = (0u32, 0u32);
1591    for line in diff.lines() {
1592        if old_left > 0 || new_left > 0 {
1593            match line.as_bytes().first() {
1594                Some(b'+') => new_left = new_left.saturating_sub(1),
1595                Some(b'-') => old_left = old_left.saturating_sub(1),
1596                Some(b'\\') => {}
1597                _ => {
1598                    old_left = old_left.saturating_sub(1);
1599                    new_left = new_left.saturating_sub(1);
1600                }
1601            }
1602            continue;
1603        }
1604        if line.starts_with("diff --git ") {
1605            path = None;
1606        } else if let Some(rest) = line.strip_prefix("+++ ") {
1607            // git ends the line with a tab when the path has a space in it,
1608            // so that a patch tool can tell where the name stops.
1609            let rest = if rest.contains(' ') {
1610                rest.strip_suffix('\t').unwrap_or(rest)
1611            } else {
1612                rest
1613            };
1614            path = rest
1615                .strip_prefix("b/")
1616                .filter(|p| !p.is_empty())
1617                .map(str::to_string);
1618        } else if let Some(((_, old_len), (start, len))) = hunk_header(line) {
1619            old_left = old_len;
1620            new_left = len;
1621            if let (Some(path), true) = (&path, len > 0 && start > 0) {
1622                out.entry(path.clone())
1623                    .or_default()
1624                    .push((start, start + len - 1));
1625            }
1626        }
1627    }
1628    out
1629}
1630
1631/// `@@ -a,b +c,d @@`, as `((a, b), (c, d))`. A count left out is one.
1632fn hunk_header(line: &str) -> Option<((u32, u32), (u32, u32))> {
1633    let ranges = line.strip_prefix("@@ -")?.split_once(" @@")?.0;
1634    let (old, new) = ranges.split_once(" +")?;
1635    let range = |s: &str| -> Option<(u32, u32)> {
1636        match s.split_once(',') {
1637            Some((start, len)) => Some((start.parse().ok()?, len.parse().ok()?)),
1638            None => Some((s.parse().ok()?, 1)),
1639        }
1640    };
1641    Some((range(old)?, range(new)?))
1642}
1643
1644fn exact_comment<'a>(comments: &'a [Value], body: &str) -> Option<&'a Value> {
1645    comments.iter().find(|comment| {
1646        comment
1647            .get("body")
1648            .and_then(Value::as_str)
1649            .is_some_and(|seen| seen == body)
1650    })
1651}
1652
1653/// Where a REST row lives on github.com, for a person to open.
1654fn html_url(row: &Value) -> Option<String> {
1655    row.get("html_url")
1656        .and_then(Value::as_str)
1657        .filter(|url| !url.is_empty())
1658        .map(str::to_string)
1659}
1660
1661/// The comment `gh pr comment` says it made. It prints the address on a line
1662/// of its own, and nothing else is promised about what is around it.
1663fn printed_url(stdout: &str) -> Option<String> {
1664    stdout
1665        .lines()
1666        .map(str::trim)
1667        .find(|line| line.starts_with("https://"))
1668        .map(str::to_string)
1669}
1670
1671/// The review the reviews API says it made, from the row it answers with.
1672fn review_url(response: &str) -> Option<String> {
1673    html_url(&serde_json::from_str(response).ok()?)
1674}
1675
1676fn reconcile_comment_post(
1677    number: i64,
1678    body: &str,
1679    post_error: crate::error::SparError,
1680    comments: Result<Vec<Value>>,
1681) -> Result<Option<String>> {
1682    match comments {
1683        Ok(comments) => match exact_comment(&comments, body) {
1684            Some(comment) => Ok(html_url(comment)),
1685            None => Err(post_error),
1686        },
1687        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1688            "could not comment on #{number}. {} The result could not be verified: {}",
1689            post_error.last_line(),
1690            read_error.last_line()
1691        ))),
1692    }
1693}
1694
1695fn reconcile_issue_edit(
1696    number: i64,
1697    wanted: &str,
1698    edit_error: crate::error::SparError,
1699    observed: Result<String>,
1700) -> Result<()> {
1701    match observed {
1702        Ok(body) if body == wanted => Ok(()),
1703        Ok(_) => Err(spar_err!(
1704            "could not rewrite the body of #{number}. {}",
1705            edit_error.last_line()
1706        )),
1707        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1708            "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1709            edit_error.last_line(),
1710            read_error.last_line()
1711        ))),
1712    }
1713}
1714
1715fn issue_url_has_number(url: &str) -> bool {
1716    url.trim()
1717        .rsplit('/')
1718        .next()
1719        .and_then(|tail| tail.parse::<i64>().ok())
1720        .is_some_and(|number| number > 0)
1721}
1722
1723fn reconcile_issue_creation(
1724    title: &str,
1725    created: Result<String>,
1726    found: Result<Option<ExistingIssue>>,
1727) -> Result<String> {
1728    match (created, found) {
1729        (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1730        (_, Ok(Some(issue))) => Ok(issue.url),
1731        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1732            "issue creation reported success but no matching issue was found for {title:?}"
1733        ))),
1734        (Err(create), Ok(None)) => Err(spar_err!(
1735            "could not file issue {title:?}. {}",
1736            create.last_line()
1737        )),
1738        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1739            "issue creation reported success for {title:?}, but it could not be verified. {}",
1740            check.last_line()
1741        ))),
1742        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1743            "could not file issue {title:?}. {} The result could not be verified: {}",
1744            create.last_line(),
1745            check.last_line()
1746        ))),
1747    }
1748}
1749
1750fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1751    if output.trim().is_empty() {
1752        return Ok(None);
1753    }
1754    for line in output.lines() {
1755        let mut fields = line.split_whitespace();
1756        let oid = fields.next().unwrap_or_default();
1757        let name = fields.next().unwrap_or_default();
1758        if name == remote_ref && !oid.is_empty() {
1759            return Ok(Some(oid.to_string()));
1760        }
1761    }
1762    Err(spar_err!(
1763        "origin returned an unexpected ref listing for {remote_ref}"
1764    ))
1765}
1766
1767fn reconcile_failed_split_push(
1768    branch: &str,
1769    push_error: crate::error::SparError,
1770    local: Result<String>,
1771    remote: Result<String>,
1772) -> std::result::Result<(), SplitPushError> {
1773    let remote_ref = format!("refs/heads/{branch}");
1774    match (local, remote) {
1775        (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1776            Ok(Some(oid)) if oid == local.trim() => Ok(()),
1777            Ok(_) => Err(SplitPushError::new(
1778                format!(
1779                    "could not create origin/{branch}. {} The remote branch is absent or points \
1780                     somewhere else. Nothing was overwritten.",
1781                    push_error.last_line()
1782                ),
1783                false,
1784            )),
1785            Err(check) => Err(SplitPushError::new(
1786                format!(
1787                    "could not confirm whether origin/{branch} was created. {} The remote result \
1788                     could not be verified: {}",
1789                    push_error.last_line(),
1790                    check.last_line()
1791                ),
1792                true,
1793            )),
1794        },
1795        (local, remote) => {
1796            let check = match (local, remote) {
1797                (Err(local), Err(remote)) => format!(
1798                    "the local commit could not be read: {}; origin could not be read: {}",
1799                    local.last_line(),
1800                    remote.last_line()
1801                ),
1802                (Err(local), _) => {
1803                    format!("the local commit could not be read: {}", local.last_line())
1804                }
1805                (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1806                _ => unreachable!(),
1807            };
1808            Err(SplitPushError::new(
1809                format!(
1810                    "could not confirm whether origin/{branch} was created. {} The result could \
1811                     not be verified because {check}",
1812                    push_error.last_line()
1813                ),
1814                true,
1815            ))
1816        }
1817    }
1818}
1819
1820impl Repo {
1821    pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1822        let root =
1823            std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1824        // A linked worktree has a `.git` file rather than a directory, and a
1825        // bare-ish layout can have neither, so ask git instead of guessing.
1826        let inside = proc::run_str(
1827            &["git", "rev-parse", "--is-inside-work-tree"],
1828            &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1829        )
1830        .unwrap_or_default();
1831        if inside.trim() != "true" {
1832            bail!("not a git repository: {}", root.display());
1833        }
1834        let repo = Self {
1835            root,
1836            style: cfg.style.clone(),
1837            branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1838            state_store: cfg.loop_cfg.state_store,
1839            followups: cfg.loop_cfg.followups,
1840            drafts: cfg.loop_cfg.drafts,
1841            fork_when_denied: cfg.loop_cfg.fork_when_denied,
1842            setup: cfg.loop_cfg.setup.clone(),
1843            isolate_reads: cfg.loop_cfg.worktrees,
1844            keep_worktrees: cfg.loop_cfg.keep_worktrees,
1845            fork: OnceLock::new(),
1846            viewer: OnceLock::new(),
1847            checkpoints: Mutex::new(BTreeMap::new()),
1848            writes: WriteStats::default(),
1849        };
1850        repo.self_exclude();
1851        Ok(repo)
1852    }
1853
1854    /// Keep spar's own scratch directories out of the target repo's
1855    /// `git status`.
1856    ///
1857    /// Written to `.git/info/exclude`, never to a tracked `.gitignore`: this is
1858    /// somebody else's repository and spar has no business committing to it.
1859    /// Best effort and silent on failure, because a read-only git directory is
1860    /// not a reason to abandon a run.
1861    fn self_exclude(&self) {
1862        let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1863        let git_dir = git_dir.trim();
1864        if git_dir.is_empty() {
1865            return;
1866        }
1867        let path = Path::new(git_dir).join("info").join("exclude");
1868        let existing = std::fs::read_to_string(&path).unwrap_or_default();
1869
1870        // The marker too. It is written into whichever directory the call ran
1871        // in, which for a shared checkout is the repository root, and an
1872        // untracked file there is one `git add -A` away from being committed.
1873        // The error names its path, which is how a person is meant to find it.
1874        let wanted = [
1875            format!("/{WORKTREE_DIR}/"),
1876            format!("/{STATE_DIR}/"),
1877            format!("{RECOVERY_MARKER_PREFIX}*"),
1878        ];
1879        let missing: Vec<&String> = wanted
1880            .iter()
1881            .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1882            .collect();
1883        if missing.is_empty() {
1884            return;
1885        }
1886
1887        use std::io::Write;
1888        if let Some(parent) = path.parent() {
1889            let _ = std::fs::create_dir_all(parent);
1890        }
1891        let mut block = String::new();
1892        if !existing.is_empty() && !existing.ends_with('\n') {
1893            block.push('\n');
1894        }
1895        block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1896        for line in missing {
1897            block.push_str(line);
1898            block.push('\n');
1899        }
1900        if let Ok(mut file) = std::fs::OpenOptions::new()
1901            .create(true)
1902            .append(true)
1903            .open(&path)
1904        {
1905            let _ = file.write_all(block.as_bytes());
1906        }
1907    }
1908
1909    pub fn root(&self) -> &Path {
1910        &self.root
1911    }
1912
1913    pub(crate) fn write_summary(&self) -> WriteSummary {
1914        WriteSummary {
1915            attempted: self.writes.attempted.load(Ordering::Relaxed),
1916            failed: self.writes.failed.load(Ordering::Relaxed),
1917        }
1918    }
1919
1920    pub(crate) fn record_write<T, E>(
1921        &self,
1922        result: std::result::Result<T, E>,
1923    ) -> std::result::Result<T, E> {
1924        self.record_write_outcome(result.is_err());
1925        result
1926    }
1927
1928    pub(crate) fn record_failed_write<T, E>(
1929        &self,
1930        result: std::result::Result<T, E>,
1931    ) -> std::result::Result<T, E> {
1932        if result.is_err() {
1933            self.record_write_outcome(true);
1934        }
1935        result
1936    }
1937
1938    fn record_write_outcome(&self, failed: bool) {
1939        self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1940        if failed {
1941            self.writes.failed.fetch_add(1, Ordering::Relaxed);
1942        }
1943    }
1944
1945    // -- gates ------------------------------------------------------------
1946
1947    /// Scrub, then verify. A leak here reaches GitHub, so it is a hard error
1948    /// rather than a warning: silent partial compliance is how a style rule
1949    /// erodes over a long run.
1950    pub fn clean(&self, text: &str) -> Result<String> {
1951        let out = style::scrub(text, &self.style);
1952        let bad = style::violations(&out, &self.style);
1953        if !bad.is_empty() {
1954            bail!(
1955                "style gate could not clean text ({}): {}",
1956                bad.join(", "),
1957                style::clip(&out, 300)
1958            );
1959        }
1960        Ok(out)
1961    }
1962
1963    /// Clean, and hold to a length budget. For anything a model wrote.
1964    pub fn clean_body(&self, text: &str) -> Result<String> {
1965        self.clean(&style::body(text, &self.style))
1966    }
1967
1968    /// The same, with an issue's far larger budget and its exemption for code.
1969    pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1970        self.clean(&style::issue_body(text, &self.style))
1971    }
1972
1973    /// The single transform every outbound title goes through.
1974    ///
1975    /// Scrub first, clip second, and never the other way round. Clipping first
1976    /// lets the scrub lengthen the result past the budget (an em dash becomes
1977    /// two characters), so a second pass would clip again and produce a
1978    /// different string. That broke follow-up deduplication silently: the
1979    /// lookup searched for one title while GitHub had stored another, no match
1980    /// was ever found, and a fresh duplicate issue was filed every review
1981    /// round. Doing it in this order makes the transform idempotent, which the
1982    /// tests assert.
1983    pub fn clean_title(&self, text: &str) -> Result<String> {
1984        Ok(style::title(&self.clean(text)?, &self.style))
1985    }
1986
1987    pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1988        let title = self.record_failed_write(self.clean_title(text))?;
1989        if title.trim().is_empty() {
1990            return self.record_failed_write(Err(spar_err!(
1991                "nothing left of the title after cleaning it"
1992            )));
1993        }
1994        Ok(title)
1995    }
1996
1997    pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1998        if self.followups == Followups::Issues {
1999            self.clean_nonempty_title_for_write(text)
2000        } else {
2001            self.clean_title(text)
2002        }
2003    }
2004
2005    // -- git --------------------------------------------------------------
2006
2007    fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
2008        ExecOpts::new()
2009            .cwd(cwd.unwrap_or(&self.root))
2010            .check(check)
2011            .timeout_secs(600)
2012    }
2013
2014    pub fn git(&self, args: &[&str]) -> Result<String> {
2015        self.git_at(None, args)
2016    }
2017
2018    pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
2019        let argv = git_without_maintenance_argv(args);
2020        proc::run(&argv, &self.git_opts(cwd, true))
2021    }
2022
2023    /// Run a parent-side Git operation without inherited background helpers.
2024    ///
2025    /// Editing calls are untrusted. Once one returns, status, staging, and
2026    /// committing happen in this process, so an inherited fsmonitor or automatic
2027    /// maintenance command must not become a way to execute outside its sandbox.
2028    fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
2029        let argv = git_without_automation_argv(args);
2030        proc::run(
2031            &argv,
2032            &self.git_opts(Some(cwd), true).stop_descendants(true),
2033        )
2034    }
2035
2036    fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
2037        let argv = git_without_automation_argv(args);
2038        proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
2039            .map(|output| output.ok())
2040    }
2041
2042    /// Run git, tolerating failure. Returns whatever landed on stdout.
2043    pub fn git_try(&self, args: &[&str]) -> String {
2044        self.git_try_at(None, args)
2045    }
2046
2047    pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
2048        let argv = git_without_maintenance_argv(args);
2049        proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
2050    }
2051
2052    /// The base branch the remote actually points at, rather than assuming
2053    /// `main`. Falls back to the configured value when there is no origin.
2054    pub fn default_branch(&self, configured: &str) -> String {
2055        let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
2056        match refname.trim().rsplit('/').next() {
2057            Some(name) if !name.is_empty() => name.to_string(),
2058            _ => configured.to_string(),
2059        }
2060    }
2061
2062    // -- branch naming and ownership --------------------------------------
2063    //
2064    // Branch names default to `issue-N`, which is exactly what a person would
2065    // name a branch by hand. Ownership therefore cannot be inferred from the
2066    // name, so every branch spar creates is recorded and cleanup only ever
2067    // touches what is in that record.
2068
2069    pub fn branch_for_issue(&self, issue: i64) -> String {
2070        format!("{}issue-{issue}", self.branch_prefix)
2071    }
2072
2073    pub fn branch_for_pr(&self, number: i64) -> String {
2074        format!("{}pr-{number}", self.branch_prefix)
2075    }
2076
2077    /// One part of a split, numbered from 1 within its parent.
2078    ///
2079    /// Its own namespace rather than `issue-N`, because the parts of a split
2080    /// pull request have no issue of their own and would otherwise collide with
2081    /// the branch of the issue that happens to share the parent's number.
2082    ///
2083    /// The name a part is tried on first. `worktree_for_split` may end up on a
2084    /// suffixed one, because this name is not free forever.
2085    pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
2086        format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
2087    }
2088
2089    fn ledger_path(&self) -> PathBuf {
2090        self.root.join(STATE_DIR).join("branches.json")
2091    }
2092
2093    pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
2094        std::fs::read_to_string(self.ledger_path())
2095            .ok()
2096            .and_then(|text| serde_json::from_str(&text).ok())
2097            .unwrap_or_default()
2098    }
2099
2100    pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
2101        let mut data = self.known_branches();
2102        data.insert(
2103            branch.to_string(),
2104            BranchRecord {
2105                kind: kind.to_string(),
2106                number,
2107            },
2108        );
2109        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
2110            logdim!("could not record branch {branch}: {e}");
2111        }
2112    }
2113
2114    pub fn forget_branch(&self, branch: &str) {
2115        let mut data = self.known_branches();
2116        if data.remove(branch).is_none() {
2117            return;
2118        }
2119        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
2120            logdim!("could not update the branch record: {e}");
2121        }
2122    }
2123
2124    // -- worktrees --------------------------------------------------------
2125
2126    fn worktree_path(&self, name: &str) -> PathBuf {
2127        self.root.join(WORKTREE_DIR).join(name)
2128    }
2129
2130    /// Run `[loop] setup` in a worktree spar has just created.
2131    ///
2132    /// Before anything else looks at the tree, so the baseline an editing call
2133    /// is measured against already holds what setup wrote, and a setup that
2134    /// fails costs no agent call. The worktree is kept as it is: what an
2135    /// install writes is build output, which the next run rebuilds over.
2136    fn set_up_worktree(&self, path: &Path, label: &str) -> Result<()> {
2137        let argv = self.setup.argv();
2138        if argv.is_empty() {
2139            return Ok(());
2140        }
2141        let command = self.setup.describe();
2142        logdim!("setting up the {label}: {command}");
2143        let out = proc::exec(
2144            &argv,
2145            &ExecOpts::new()
2146                .cwd(path)
2147                .check(false)
2148                .timeout_secs(SETUP_TIMEOUT_SECS)
2149                .stop_descendants(true),
2150        )
2151        .map_err(|e| spar_err!("could not run the setup `{command}` for the {label}: {e}"))?;
2152        if !out.ok() {
2153            let text = format!("{}\n{}", out.stdout.trim_end(), out.stderr.trim_end());
2154            bail!(
2155                "the setup `{command}` failed in the {label} at {}, so no agent was asked to \
2156                 work in it.\n{}",
2157                path.display(),
2158                style::clip(text.trim(), 4000)
2159            );
2160        }
2161        Ok(())
2162    }
2163
2164    /// Isolate an issue so a failed run cannot poison the next one's base.
2165    pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
2166        let branch = self.branch_for_issue(issue);
2167        let path = self.worktree_path(&format!("issue-{issue}"));
2168
2169        self.refuse_issue_branch_rebuild(issue, base)?;
2170        self.refuse_dirty_worktree(
2171            &path,
2172            &format!("worktree for issue #{issue}"),
2173            Disposable::BuildOutput,
2174        )?;
2175
2176        if !self.branch_deletion_is_safe(&branch)? {
2177            bail!(
2178                "the existing branch {branch} has a tip or reflog-only commit that no surviving \
2179                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2180                 before retrying."
2181            );
2182        }
2183
2184        if !self.remove_worktree_at(&path)? {
2185            bail!(
2186                "the existing worktree for issue #{issue} could not be removed safely. Its \
2187                 branch was kept."
2188            );
2189        }
2190        if !self.delete_branch_if_safe(&branch)? {
2191            bail!(
2192                "the existing branch {branch} changed or remained checked out while its \
2193                 worktree was being rebuilt. It was kept."
2194            );
2195        }
2196
2197        if let Some(parent) = path.parent() {
2198            std::fs::create_dir_all(parent)
2199                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2200        }
2201
2202        let path_str = path.display().to_string();
2203        let remote_start = format!("origin/{base}");
2204        let created = self
2205            .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
2206            .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
2207
2208        // Recorded on both paths: an unrecorded branch is one cleanup will
2209        // never remove, and the fallback creates a branch just the same.
2210        created.map_err(|e| {
2211            spar_err!(
2212                "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
2213                 and does `origin` exist?",
2214                e.last_line()
2215            )
2216        })?;
2217        self.record_branch(&branch, "issue", issue);
2218        self.set_up_worktree(&path, &format!("worktree for issue #{issue}"))?;
2219        Ok((path, branch))
2220    }
2221
2222    /// Refuse to reset the local or remote branch assigned to an issue when it
2223    /// carries work that no pull request preserves.
2224    ///
2225    /// Both linked-worktree mode and shared-checkout mode rebuild the same
2226    /// branch name. Keeping the guard here prevents either path from silently
2227    /// replacing recovery commits left by an earlier run.
2228    pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
2229        let branch = self.branch_for_issue(issue);
2230        let base_remote_ref = format!("refs/heads/{base}");
2231        let base_tracking_ref = format!("refs/remotes/origin/{base}");
2232        let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
2233        self.git(&["fetch", "--no-tags", "origin", &base_refspec])
2234            .map_err(|e| {
2235                spar_err!(
2236                    "could not refresh origin/{base} before checking issue #{issue}: {}",
2237                    e.last_line()
2238                )
2239            })?;
2240
2241        if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
2242            let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
2243            if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
2244                bail!(
2245                    "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
2246                     pull request accounts for them. Rebuilding it would force push over that \
2247                     work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
2248                     it, or delete it with `git push origin --delete {branch}` if the remote \
2249                     branch is no longer needed."
2250                );
2251            }
2252        }
2253
2254        // The same question of the fork a refused push would go to, asked here
2255        // rather than after an agent has spent its call implementing.
2256        if self.fork_when_denied && self.origin_refuses_push() {
2257            match self.existing_fork() {
2258                Ok(Some(fork)) => {
2259                    self.fork_branch_lease(&fork, &branch, base)?;
2260                }
2261                Ok(None) => {}
2262                Err(e) => logdim!("{e} It is checked again before anything is pushed to it."),
2263            }
2264        }
2265
2266        let local_ref = format!("refs/heads/{branch}");
2267        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2268            let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
2269            let recorded_pr = self
2270                .known_branches()
2271                .get(&branch)
2272                .is_some_and(|record| record.kind == "pr");
2273            let preserved = ahead == 0
2274                || if recorded_pr {
2275                    self.local_branch_is_preserved(&branch)?
2276                } else {
2277                    self.pull_request_holds(&branch, &local_ref, base)
2278                };
2279            if !preserved {
2280                let listed = self
2281                    .commit_lines(&self.root, &local_ref, base)
2282                    .iter()
2283                    .map(|line| format!("  {line}"))
2284                    .collect::<Vec<_>>()
2285                    .join("\n");
2286                bail!(
2287                    "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
2288                     no pull request preserves them. Rebuilding it would delete the only copy.\n\
2289                     {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
2290                     it, or delete it with `git branch -D {branch}` if it is stale."
2291                );
2292            }
2293        }
2294        Ok(())
2295    }
2296
2297    fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
2298        let live_ref = format!("refs/heads/{branch}");
2299        let tracking_ref = format!("refs/remotes/origin/{branch}");
2300        let listed = self
2301            .git(&["ls-remote", "--heads", "origin", &live_ref])
2302            .map_err(|e| {
2303                spar_err!(
2304                    "could not verify whether origin/{branch} still exists: {}",
2305                    e.last_line()
2306                )
2307            })?;
2308
2309        if remote_head_oid(&listed, &live_ref)?.is_some() {
2310            let refspec = format!("+{live_ref}:{tracking_ref}");
2311            self.git(&["fetch", "--no-tags", "origin", &refspec])
2312                .map_err(|e| {
2313                    spar_err!(
2314                        "origin/{branch} exists but its tracking ref could not be refreshed: {}",
2315                        e.last_line()
2316                    )
2317                })?;
2318            if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2319                bail!("origin/{branch} was fetched but its tracking ref is missing");
2320            }
2321            return Ok(Some(tracking_ref));
2322        }
2323
2324        if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2325            return Ok(None);
2326        }
2327        let expected = self
2328            .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
2329            .trim()
2330            .to_string();
2331        self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
2332            .map_err(|e| {
2333                spar_err!(
2334                    "could not discard stale origin/{branch} tracking ref safely: {}",
2335                    e.last_line()
2336                )
2337            })?;
2338        if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2339            bail!(
2340                "origin/{branch} changed while its stale tracking ref was being removed. It was \
2341                 kept."
2342            );
2343        }
2344        Ok(None)
2345    }
2346
2347    /// Whether a pull request from `branch` already holds every commit `refname`
2348    /// has beyond `base`.
2349    ///
2350    /// GitHub serves `refs/pull/N/head` for as long as the repository lives, so
2351    /// commits that reached a pull request outlive the branch they were pushed
2352    /// from. A matching branch name does not establish that on its own: an
2353    /// issue worked twice reuses the name, and the merged pull request from the
2354    /// first round says nothing about where the second round's commits are.
2355    fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
2356        self.prs_for_branch(branch)
2357            .iter()
2358            .any(|pr| self.pr_head_holds(pr.number, refname, base))
2359    }
2360
2361    fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
2362        let head = format!("refs/spar/pr-head/{number}");
2363        let refspec = format!("+refs/pull/{number}/head:{head}");
2364        if self.git(&["fetch", "origin", &refspec]).is_err() {
2365            return false;
2366        }
2367        let held = self.commits_held_by(refname, base, &head);
2368        self.git_try(&["update-ref", "-d", &head]);
2369        held
2370    }
2371
2372    pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
2373        let argv = vec![
2374            "git".to_string(),
2375            "merge-base".to_string(),
2376            "--is-ancestor".to_string(),
2377            older.to_string(),
2378            newer.to_string(),
2379        ];
2380        let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
2381        match out.code {
2382            0 => Ok(true),
2383            1 => Ok(false),
2384            _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
2385        }
2386    }
2387
2388    fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
2389        let head = format!("refs/spar/pr-head/{number}");
2390        let refspec = format!("+refs/pull/{number}/head:{head}");
2391        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2392            spar_err!(
2393                "could not verify the immutable head of PR #{number}: {}",
2394                e.last_line()
2395            )
2396        })?;
2397        let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
2398        self.git_try(&["update-ref", "-d", &head]);
2399        held
2400    }
2401
2402    fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
2403        let text = self.gh(&[
2404            "pr",
2405            "list",
2406            "--head",
2407            branch,
2408            "--state",
2409            "all",
2410            "--json",
2411            "number,url,title",
2412        ])?;
2413        serde_json::from_str(text.trim())
2414            .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
2415    }
2416
2417    fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
2418        let branch_ref = format!("refs/heads/{branch}");
2419        if record.kind == "pr" {
2420            return self.pr_head_contains_checked(record.number, &branch_ref);
2421        }
2422        let prs = self.branch_prs_checked(branch)?;
2423        if prs.is_empty() {
2424            return Ok(false);
2425        }
2426        for pr in prs {
2427            if self.pr_head_contains_checked(pr.number, &branch_ref)? {
2428                return Ok(true);
2429            }
2430        }
2431        Ok(false)
2432    }
2433
2434    fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
2435        let local_ref = format!("refs/heads/{branch}");
2436        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2437            return Ok(true);
2438        }
2439        let oid = self
2440            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2441            .trim()
2442            .to_string();
2443        let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
2444        if !durable_tip {
2445            let remote_ref = format!("refs/heads/{branch}");
2446            let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
2447            durable_tip = remote.lines().any(|line| {
2448                line.split_whitespace()
2449                    .next()
2450                    .is_some_and(|remote_oid| remote_oid == oid)
2451            });
2452        }
2453        if !durable_tip {
2454            if let Some(record) = self.known_branches().get(branch) {
2455                durable_tip = self.branch_is_preserved_checked(branch, record)?;
2456            }
2457        }
2458        if !durable_tip {
2459            return Ok(false);
2460        }
2461        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2462    }
2463
2464    /// Delete a branch only if its exact current tip and reflog are still safe.
2465    /// The expected old value makes a concurrent ref update fail instead of
2466    /// deleting work that appeared after the preservation check.
2467    fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
2468        let local_ref = format!("refs/heads/{branch}");
2469        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2470            return Ok(true);
2471        }
2472        let expected = self
2473            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2474            .trim()
2475            .to_string();
2476        if !self.branch_deletion_is_safe(branch)? {
2477            return Ok(false);
2478        }
2479        let checked_out = self
2480            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
2481            .lines()
2482            .any(|line| line == format!("branch {local_ref}"));
2483        if checked_out {
2484            return Ok(false);
2485        }
2486        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2487        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2488    }
2489
2490    fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
2491        let local_ref = review_ref(number);
2492        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2493            return Ok(true);
2494        }
2495        let oid = self
2496            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2497            .trim()
2498            .to_string();
2499        if !self.pr_head_contains_checked(number, &local_ref)? {
2500            return Ok(false);
2501        }
2502        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2503    }
2504
2505    fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
2506        let local_ref = review_ref(number);
2507        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2508            return Ok(true);
2509        }
2510        let expected = self
2511            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2512            .trim()
2513            .to_string();
2514        if !self.review_ref_deletion_is_safe(number)? {
2515            return Ok(false);
2516        }
2517        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2518        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2519    }
2520
2521    /// Whether `other` already contains every commit `branch` has beyond
2522    /// `base`. False when either ref fails to resolve, so a ref that is not
2523    /// there cannot vouch for anything.
2524    pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
2525        let range = format!("{}..{branch}", self.base_ref(&self.root, base));
2526        self.git_try(&["rev-list", "--count", &range, "--not", other])
2527            .trim()
2528            == "0"
2529    }
2530
2531    pub fn worktree_remove(&self, issue: i64) -> bool {
2532        let path = self.worktree_path(&format!("issue-{issue}"));
2533        match self.remove_worktree_at(&path) {
2534            Ok(removed) => removed,
2535            Err(error) => {
2536                logdim!(
2537                    "kept {} because removal did not reach a confirmed quiet point: {}",
2538                    path.display(),
2539                    error.last_line()
2540                );
2541                false
2542            }
2543        }
2544    }
2545
2546    /// Verify both the common Git directory and the worktree top level.
2547    ///
2548    /// A stale worktree entry is not ownership proof. An unrelated repository
2549    /// can later occupy the same path and must survive cleanup.
2550    fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
2551        let wanted = std::fs::canonicalize(path)
2552            .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
2553        // Every SPAR worktree path is built from the canonical repository root.
2554        // A different canonical path therefore means the final component or
2555        // one of its parents is a symlink. Passing that alias to `git worktree
2556        // remove` can delete the worktree at its real target.
2557        if wanted != path {
2558            return Ok(false);
2559        }
2560        let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
2561            let raw = PathBuf::from(value.trim());
2562            let joined = if raw.is_absolute() {
2563                raw
2564            } else {
2565                cwd.join(raw)
2566            };
2567            std::fs::canonicalize(&joined)
2568                .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
2569        };
2570        let expected =
2571            self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
2572        let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
2573        let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
2574        let expected = resolve(&self.root, &expected)?;
2575        let actual = resolve(path, &actual)?;
2576        let top = resolve(path, &top)?;
2577        Ok(expected == actual && top == wanted)
2578    }
2579
2580    /// Remove only a worktree that belongs to this repository.
2581    ///
2582    /// The path sits under a predictable directory, but that does not establish
2583    /// ownership. A clean independent repository at the same path must survive
2584    /// even when `git worktree remove` rejects it.
2585    fn remove_worktree_at_with_force(
2586        &self,
2587        path: &Path,
2588        force: bool,
2589        disposable: Disposable,
2590    ) -> Result<bool> {
2591        let existed = path.exists();
2592        if path.exists() {
2593            match self.worktree_belongs_to_repo(path) {
2594                Ok(true) => {}
2595                Ok(false) => {
2596                    logdim!(
2597                        "kept {} because it is not a worktree owned by this repository",
2598                        path.display()
2599                    );
2600                    return Ok(false);
2601                }
2602                Err(e) => {
2603                    logdim!(
2604                        "kept {} because its worktree ownership could not be verified: {}",
2605                        path.display(),
2606                        e.last_line()
2607                    );
2608                    return Ok(false);
2609                }
2610            }
2611            if !force {
2612                match self.has_recoverable_work(path, disposable) {
2613                    Ok(true) => {
2614                        logdim!(
2615                            "kept {} because it contains recoverable files or repository state",
2616                            path.display()
2617                        );
2618                        return Ok(false);
2619                    }
2620                    Err(e) => {
2621                        logdim!(
2622                            "kept {} because its recoverable state could not be checked: {}",
2623                            path.display(),
2624                            e.last_line()
2625                        );
2626                        return Ok(false);
2627                    }
2628                    Ok(false) => {}
2629                }
2630            }
2631        }
2632        // Git refuses to remove a worktree holding any untracked file, which is
2633        // the same question SPAR has just answered in more detail: under
2634        // `ReviewOutput` the checks above have already established that nothing
2635        // there is worth keeping. `--force` here is that answer being carried
2636        // out, not the check being skipped, which is what `force` means.
2637        let path_str = path.display().to_string();
2638        let command_ok = if force || disposable == Disposable::ReviewOutput {
2639            self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
2640        } else {
2641            self.git_try_without_automation(&["worktree", "remove", &path_str])?
2642        };
2643        Ok((command_ok || !existed) && !path.exists())
2644    }
2645
2646    fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
2647        self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
2648    }
2649
2650    /// Remove a review checkout that is about to be rebuilt from origin.
2651    fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
2652        self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
2653    }
2654
2655    /// Force removal is reserved for the explicit `clean --all` path.
2656    fn remove_worktree_at_force(&self, path: &Path) -> bool {
2657        match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
2658            Ok(removed) => removed,
2659            Err(error) => {
2660                logdim!(
2661                    "kept {} because removal did not reach a confirmed quiet point: {}",
2662                    path.display(),
2663                    error.last_line()
2664                );
2665                false
2666            }
2667        }
2668    }
2669
2670    /// Remove a worktree only after a caller has verified it is unchanged.
2671    ///
2672    /// There is deliberately no force fallback, so Git can still refuse a
2673    /// removal if tracked or non-ignored work appears after the final check.
2674    fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
2675        if path.exists() && !self.worktree_belongs_to_repo(path)? {
2676            bail!(
2677                "{} is not a worktree owned by this repository, so it was kept",
2678                path.display()
2679            );
2680        }
2681        if path.exists() && self.has_recoverable_work(path, disposable)? {
2682            bail!(
2683                "the verified worktree at {} contains recoverable files or repository state. It \
2684                 was kept.",
2685                path.display()
2686            );
2687        }
2688        let path_str = path.display().to_string();
2689        self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
2690            .map_err(|e| {
2691                e.with_message(format!(
2692                    "could not remove the verified worktree at {}: {}. It was kept.",
2693                    path.display(),
2694                    e.last_line()
2695                ))
2696            })?;
2697        Ok(!path.exists())
2698    }
2699
2700    fn refuse_dirty_worktree(
2701        &self,
2702        path: &Path,
2703        label: &str,
2704        disposable: Disposable,
2705    ) -> Result<()> {
2706        if !path.is_dir() {
2707            return Ok(());
2708        }
2709        let has_files = std::fs::read_dir(path)
2710            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2711            .next()
2712            .is_some();
2713        let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
2714            spar_err!(
2715                "could not verify whether the existing {label} at {} belongs to this repository, \
2716                 so it was kept: {}",
2717                path.display(),
2718                e.last_line()
2719            )
2720        })?;
2721        if !owned {
2722            if has_files {
2723                bail!(
2724                    "the existing {label} at {} is not a worktree owned by \
2725                     this repository. Refusing to remove it.",
2726                    path.display()
2727                );
2728            }
2729            return Ok(());
2730        }
2731        if !path.join(".git").exists() {
2732            if has_files {
2733                bail!(
2734                    "the existing {label} at {} is not a readable Git worktree and is not empty. \
2735                     Refusing to remove it.",
2736                    path.display()
2737                );
2738            }
2739            return Ok(());
2740        }
2741        let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2742            spar_err!(
2743                "could not verify whether the existing {label} at {} is clean, so it was kept: \
2744                 {}",
2745                path.display(),
2746                e.last_line()
2747            )
2748        })?;
2749        if dirty {
2750            bail!(
2751                "the existing {label} contains uncommitted changes or ignored files at {}. \
2752                 Rebuilding it would delete those files.\nCommit or recover them before running this \
2753                 command again, or use `spar clean --all` if they are not needed.",
2754                path.display()
2755            );
2756        }
2757        Ok(())
2758    }
2759
2760    /// Check an existing PR branch out into an isolated worktree, from
2761    /// wherever `target` says the branch is published.
2762    pub fn worktree_for_pr(&self, pr: &PrView, target: &PushTarget) -> Result<(PathBuf, String)> {
2763        let head = pr.head_ref_name.clone();
2764        if head.trim().is_empty() {
2765            bail!("PR #{} has no head branch to check out", pr.number);
2766        }
2767        let path = self.worktree_path(&format!("pr-{}", pr.number));
2768        let local = self.branch_for_pr(pr.number);
2769
2770        let unfetched = |e: crate::error::SparError| {
2771            spar_err!(
2772                "could not fetch the branch behind PR #{}: {}",
2773                pr.number,
2774                e.last_line()
2775            )
2776        };
2777        // What the worktree starts from, the ref behind it, and what to call
2778        // it when saying so.
2779        let (start, start_ref, shown) = match target {
2780            PushTarget::Origin => {
2781                self.git(&["fetch", "origin", &head]).map_err(unfetched)?;
2782                let start = format!("origin/{head}");
2783                (start.clone(), format!("refs/remotes/origin/{head}"), start)
2784            }
2785            // A fork has no remote here to track it, so its branch is
2786            // fetched into a ref of spar's own.
2787            PushTarget::Fork(fork) => {
2788                let start_ref = format!("refs/spar/fork/{head}");
2789                let refspec = format!("+refs/heads/{head}:{start_ref}");
2790                self.git(&["fetch", "--no-tags", &fork.url, &refspec])
2791                    .map_err(unfetched)?;
2792                (start_ref.clone(), start_ref, target.describe(&head))
2793            }
2794        };
2795        let local_ref = format!("refs/heads/{local}");
2796        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2797            let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2798            if unpushed > 0 {
2799                bail!(
2800                    "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2801                     on {shown}. Rebuilding it would delete their branch.\nInspect the worktree at \
2802                     {} and push or recover those commits before running this command again.",
2803                    pr.number,
2804                    path.display()
2805                );
2806            }
2807        }
2808        self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2809        self.refuse_dirty_worktree(
2810            &path,
2811            &format!("worktree for PR #{}", pr.number),
2812            Disposable::BuildOutput,
2813        )?;
2814        if !self.branch_deletion_is_safe(&local)? {
2815            bail!(
2816                "the existing branch {local} has a tip or reflog-only commit that no surviving \
2817                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2818                 before retrying."
2819            );
2820        }
2821        if !self.remove_worktree_at(&path)? {
2822            bail!(
2823                "the existing worktree for PR #{} could not be removed safely. Its branch was \
2824                 kept.",
2825                pr.number
2826            );
2827        }
2828        if !self.delete_branch_if_safe(&local)? {
2829            bail!(
2830                "the existing branch {local} changed or remained checked out while the PR \
2831                 worktree was being rebuilt. It was kept."
2832            );
2833        }
2834
2835        let path_str = path.display().to_string();
2836        self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2837        self.record_branch(&local, "pr", pr.number);
2838        self.set_up_worktree(&path, &format!("worktree for PR #{}", pr.number))?;
2839        Ok((path, head))
2840    }
2841
2842    /// A retry needs a fresh checkout, not permission to delete ignored data.
2843    /// Keep that data and its branch when all commits are already published.
2844    fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2845        if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2846            return Ok(());
2847        }
2848        let current =
2849            self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2850        if current.trim() != branch
2851            || self.has_uncommitted_changes(path)?
2852            || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2853        {
2854            return Ok(());
2855        }
2856        static NEXT: AtomicUsize = AtomicUsize::new(0);
2857        let (name, retained) = loop {
2858            let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2859            let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2860            let retained = self.worktree_path(&name);
2861            if !retained
2862                .try_exists()
2863                .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2864                && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2865            {
2866                break (name, retained);
2867            }
2868        };
2869        // Neither command overwrites an existing destination. The branch rename
2870        // preserves its reflog, and the worktree move preserves every file.
2871        self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2872        if let Err(error) = self.git_at_without_automation(
2873            &self.root,
2874            &[
2875                "worktree",
2876                "move",
2877                &path.display().to_string(),
2878                &retained.display().to_string(),
2879            ],
2880        ) {
2881            // A locked worktree or one with submodules cannot always be moved.
2882            // Restore the branch name so a failed move does not strand a retry.
2883            let rollback =
2884                self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2885            let detail = rollback
2886                .err()
2887                .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2888                .unwrap_or_default();
2889            bail!(
2890                "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2891                retained.display(),
2892                error.last_line()
2893            );
2894        }
2895        logdim!(
2896            "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2897            retained.display()
2898        );
2899        Ok(())
2900    }
2901
2902    /// Check a pull request's head out read only, detached, with no branch.
2903    ///
2904    /// Fetches `refs/pull/N/head`, which GitHub serves for every pull request
2905    /// including one from a fork whose branch is not in this repository at all.
2906    /// That is what makes reviewing an outside contribution possible when
2907    /// pushing to it is not.
2908    ///
2909    /// Detached on purpose. Review only mode has nothing to push, and a branch
2910    /// would only invite something to try.
2911    pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2912        let path = self.worktree_path(&format!("review-{number}"));
2913        let local_ref = review_ref(number);
2914        let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2915
2916        self.refuse_review_worktree_changes(number)?;
2917
2918        if let Some(parent) = path.parent() {
2919            std::fs::create_dir_all(parent)
2920                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2921        }
2922        // Removed before the fetch, while `local_ref` still holds the head
2923        // this checkout is at. A force push moves the ref off that commit,
2924        // and the checkout's reflog would then name a commit no ref holds,
2925        // which the removal check rightly keeps as local work.
2926        if !self.remove_review_worktree_at(&path)? {
2927            bail!(
2928                "the existing review worktree for PR #{number} could not be removed safely. Its \
2929                 reference was kept."
2930            );
2931        }
2932
2933        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2934            spar_err!(
2935                "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2936                 every pull request, so this usually means the number is wrong or `origin` does \
2937                 not point at the repository the PR is on.",
2938                e.last_line()
2939            )
2940        })?;
2941
2942        let path_str = path.display().to_string();
2943        self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2944        self.set_up_worktree(&path, &format!("review worktree for PR #{number}"))?;
2945        Ok(path)
2946    }
2947
2948    fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2949        let path = self.worktree_path(&format!("review-{number}"));
2950        if !path.is_dir() {
2951            return Ok(());
2952        }
2953        let local_ref = review_ref(number);
2954        if !self.worktree_belongs_to_repo(&path)? {
2955            return Ok(());
2956        }
2957        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2958            bail!(
2959                "the existing review worktree for PR #{number} has no recorded head at \
2960                 {local_ref}. Refusing to rebuild {}.",
2961                path.display()
2962            );
2963        }
2964        let worktree_head = self.head_oid_checked(&path)?;
2965        let recorded_head = self
2966            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2967            .trim()
2968            .to_string();
2969        if worktree_head != recorded_head {
2970            bail!(
2971                "the existing review worktree for PR #{number} has a local commit that is not on \
2972                 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2973                 {} before retrying.",
2974                path.display()
2975            );
2976        }
2977        self.report_recovery_markers(&path);
2978        self.refuse_dirty_worktree(
2979            &path,
2980            &format!("review worktree for PR #{number}"),
2981            Disposable::ReviewOutput,
2982        )?;
2983        Ok(())
2984    }
2985
2986    /// Read out what an earlier run refused to explain away, before rebuilding
2987    /// over it.
2988    ///
2989    /// The marker is the only record of why a call's answer was thrown out, and
2990    /// a review checkout is rebuilt on the next run. Saying it here is what
2991    /// keeps the file from being the thing that quietly blocks the command
2992    /// instead.
2993    fn report_recovery_markers(&self, path: &Path) {
2994        let Ok(entries) = std::fs::read_dir(path) else {
2995            return;
2996        };
2997        for entry in entries.flatten() {
2998            let name = entry.file_name();
2999            let Some(name) = name.to_str() else { continue };
3000            if !name.starts_with(RECOVERY_MARKER_PREFIX) {
3001                continue;
3002            }
3003            match std::fs::read_to_string(entry.path()) {
3004                Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
3005                Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
3006            }
3007        }
3008    }
3009
3010    /// A checkout of HEAD for calls that only read, made fresh for them.
3011    ///
3012    /// With `[loop] worktrees` off the calls stay in the shared checkout,
3013    /// which is what that setting asks of every call, and so they do when
3014    /// nothing is committed yet, since there is no commit to check out.
3015    pub fn read_checkout(&self, label: &str) -> Result<ReadCheckout<'_>> {
3016        let shared = || ReadCheckout {
3017            repo: self,
3018            path: self.root.clone(),
3019            checkpoint: None,
3020        };
3021        if !self.isolate_reads {
3022            return Ok(shared());
3023        }
3024        let Some(head) = resolve_optional_commit(&self.root, "HEAD")? else {
3025            return Ok(shared());
3026        };
3027        static NEXT: AtomicUsize = AtomicUsize::new(0);
3028        let path = loop {
3029            let serial = NEXT.fetch_add(1, Ordering::Relaxed);
3030            let path = self.worktree_path(&format!(
3031                "{READ_CHECKOUT_PREFIX}{}-{serial}",
3032                std::process::id()
3033            ));
3034            if !path
3035                .try_exists()
3036                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
3037            {
3038                break path;
3039            }
3040        };
3041        if let Some(parent) = path.parent() {
3042            std::fs::create_dir_all(parent)
3043                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
3044        }
3045        let path_str = path.display().to_string();
3046        self.git(&["worktree", "add", "--detach", &path_str, &head])
3047            .map_err(|e| {
3048                spar_err!(
3049                    "could not check HEAD out for the {label}: {}",
3050                    e.last_line()
3051                )
3052            })?;
3053        let ready = self
3054            .set_up_worktree(&path, &format!("read-only checkout for the {label}"))
3055            .and_then(|()| self.worktree_checkpoint(&path));
3056        match ready {
3057            Ok(checkpoint) => Ok(ReadCheckout {
3058                repo: self,
3059                path,
3060                checkpoint: Some(checkpoint),
3061            }),
3062            Err(e) => {
3063                // No call has run in it yet, so nothing in it is anybody's.
3064                self.discard_read_checkout(&path);
3065                Err(e)
3066            }
3067        }
3068    }
3069
3070    /// Remove a read checkout once nothing in it has changed since setup.
3071    ///
3072    /// What can be left after that is ignored output from calls that were
3073    /// allowed to leave it, in a checkout nobody else works in, so it goes
3074    /// with the checkout. A recovery marker means a call's answer was already
3075    /// refused over what it did there, and that error said where to look.
3076    fn release_read_checkout(&self, path: &Path, checkpoint: &WorktreeCheckpoint) {
3077        if self.keep_worktrees {
3078            logdim!("kept the read-only checkout at {}", path.display());
3079            return;
3080        }
3081        if has_recovery_marker(path) {
3082            logdim!(
3083                "kept the read-only checkout at {} for recovery",
3084                path.display()
3085            );
3086            return;
3087        }
3088        if let Err(e) = self.require_unchanged_worktree(path, checkpoint, "read-only checkout") {
3089            logwarn!("{e}");
3090            return;
3091        }
3092        self.discard_read_checkout(path);
3093    }
3094
3095    fn discard_read_checkout(&self, path: &Path) {
3096        match self.remove_worktree_at_checked(path, Disposable::ReviewOutput) {
3097            Ok(true) => {}
3098            Ok(false) => logdim!(
3099                "kept the read-only checkout at {} because it was still there after removal",
3100                path.display()
3101            ),
3102            Err(e) => logdim!(
3103                "kept the read-only checkout at {}: {}",
3104                path.display(),
3105                e.last_line()
3106            ),
3107        }
3108    }
3109
3110    /// A worktree for one part of a split, on a new branch off `start`.
3111    ///
3112    /// `start` is the base branch for independent parts and the previous part's
3113    /// branch for stacked ones, which is the only difference between the two
3114    /// shapes at this level.
3115    ///
3116    /// The branch is whatever name was free, which is why it is returned rather
3117    /// than derived by the caller. Splitting the same pull request a second
3118    /// time would otherwise target the branch behind the first run's pull
3119    /// request. Split pushes are create-only and would refuse that target, but
3120    /// a repeated split still needs distinct branches rather than a name that
3121    /// can never be created.
3122    pub fn worktree_for_split(
3123        &self,
3124        parent: i64,
3125        index: usize,
3126        start: &str,
3127    ) -> Result<(PathBuf, String)> {
3128        let slot = self.free_split_slot(parent, index)?;
3129        let branch = format!("{}{slot}", self.branch_prefix);
3130        let path = self.worktree_path(&slot);
3131
3132        if let Some(dir) = path.parent() {
3133            std::fs::create_dir_all(dir)
3134                .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
3135        }
3136        self.refuse_dirty_worktree(
3137            &path,
3138            &format!("worktree for part {index} of PR #{parent}"),
3139            Disposable::BuildOutput,
3140        )?;
3141        // The name is free, so there is no branch to delete. A directory can
3142        // still be in the way, left by a worktree that was pruned from git's
3143        // records without being removed from disk.
3144        if !self.remove_worktree_at(&path)? {
3145            bail!(
3146                "the existing worktree for part {index} of PR #{parent} could not be removed \
3147                 safely. No branch was created."
3148            );
3149        }
3150
3151        let path_str = path.display().to_string();
3152        self.git(&["worktree", "add", "-b", &branch, &path_str, start])
3153            .map_err(|e| {
3154                spar_err!(
3155                    "could not create a worktree for part {index} of #{parent}. {}",
3156                    e.last_line()
3157                )
3158            })?;
3159        // Recorded before anything else can fail. An unrecorded branch is one
3160        // `prune_branches` will never remove.
3161        self.record_branch(&branch, "split", parent);
3162        self.set_up_worktree(&path, &format!("worktree for part {index} of PR #{parent}"))?;
3163        Ok((path, branch))
3164    }
3165
3166    /// The first part branch nothing is already sitting on.
3167    ///
3168    /// Origin as well as local, because a part's branch outlives the local one:
3169    /// a second split of the same pull request finds its own earlier branches
3170    /// deleted here but alive on origin, where the pull requests that reviewed
3171    /// them still point at them.
3172    fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
3173        for attempt in 1..=SPLIT_SLOTS {
3174            let slot = split_slot(parent, index, attempt);
3175            let branch = format!("{}{slot}", self.branch_prefix);
3176            self.git_try(&["fetch", "origin", &branch]);
3177            if !self.rev_exists(&self.root, &branch)
3178                && !self.rev_exists(&self.root, &format!("origin/{branch}"))
3179            {
3180                return Ok(slot);
3181            }
3182        }
3183        bail!(
3184            "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
3185             names are all taken. Inspect the existing branches and child pull requests. Finish \
3186             recording the earlier split, or remove every retained local worktree and branch, \
3187             child pull request, and remote split branch before starting over.",
3188            self.branch_for_split(parent, index)
3189        )
3190    }
3191
3192    /// Whether a previous attempt pushed any branch for this split.
3193    ///
3194    /// The parent comment is the normal retry marker. A branch is the fallback
3195    /// when that comment or the pull request creation failed after the push.
3196    /// Reading origin directly makes the guard survive a fresh clone.
3197    pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
3198        let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
3199        Ok(!self
3200            .git(&["ls-remote", "--heads", "origin", &pattern])?
3201            .trim()
3202            .is_empty())
3203    }
3204
3205    /// Throw one part away: its worktree, its branch, and its record.
3206    ///
3207    /// For a part that would not stand on its own. Nothing has been pushed at
3208    /// that point, so this leaves no trace anywhere but the log. Takes what
3209    /// `worktree_for_split` returned, since the name it settled on is not
3210    /// derivable from the parent and the index.
3211    pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
3212        match self.branch_deletion_is_safe(branch) {
3213            Ok(true) => {}
3214            Ok(false) => {
3215                logdim!(
3216                    "kept {branch} and {} because no surviving ref preserves its tip",
3217                    dir.display()
3218                );
3219                return;
3220            }
3221            Err(error) => {
3222                logdim!(
3223                    "kept {branch} and {} because preservation could not be verified: {}",
3224                    dir.display(),
3225                    error.last_line()
3226                );
3227                return;
3228            }
3229        }
3230        match self.remove_worktree_at(dir) {
3231            Ok(true) => match self.delete_branch_if_safe(branch) {
3232                Ok(true) => self.forget_branch(branch),
3233                Ok(false) => {
3234                    logdim!("kept {branch} because its tip or reflog changed before deletion")
3235                }
3236                Err(error) => logdim!(
3237                    "kept {branch} because deletion safety could not be rechecked: {}",
3238                    error.last_line()
3239                ),
3240            },
3241            Ok(false) => {}
3242            Err(error) => logdim!(
3243                "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
3244                dir.display(),
3245                error.last_line()
3246            ),
3247        }
3248    }
3249
3250    /// Discard one exact mechanical slice that the split workflow just made.
3251    ///
3252    /// Unlike ordinary release, this intentionally removes an unpushed commit.
3253    /// The caller supplies the exact disposable tip, and every file, worktree,
3254    /// ownership, and ref check must still match before anything is removed.
3255    pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
3256        let record = self.known_branches().get(branch).cloned();
3257        if record.is_none_or(|record| record.kind != "split") {
3258            logdim!("kept {branch} because no split branch record proves ownership");
3259            return false;
3260        }
3261        let local_ref = format!("refs/heads/{branch}");
3262        let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
3263            Ok(value) => value.trim().to_string(),
3264            Err(error) => {
3265                logdim!(
3266                    "kept {branch} because its tip could not be checked: {}",
3267                    error.last_line()
3268                );
3269                return false;
3270            }
3271        };
3272        if expected != disposable_head {
3273            logdim!("kept {branch} because it moved beyond the disposable slice");
3274            return false;
3275        }
3276        match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
3277            Ok(true) => {}
3278            Ok(false) => {
3279                logdim!(
3280                    "kept {branch} because its reflog contains work outside the disposable slice"
3281                );
3282                return false;
3283            }
3284            Err(error) => {
3285                logdim!(
3286                    "kept {branch} because its reflog could not be checked: {}",
3287                    error.last_line()
3288                );
3289                return false;
3290            }
3291        }
3292        match self.head_oid_checked(dir) {
3293            Ok(head) if head == disposable_head => {}
3294            Ok(_) => {
3295                logdim!(
3296                    "kept {branch} and {} because the worktree moved beyond the disposable slice",
3297                    dir.display()
3298                );
3299                return false;
3300            }
3301            Err(error) => {
3302                logdim!(
3303                    "kept {branch} and {} because its head could not be checked: {}",
3304                    dir.display(),
3305                    error.last_line()
3306                );
3307                return false;
3308            }
3309        }
3310        match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
3311            Ok(true) => {}
3312            Ok(false) => return false,
3313            Err(error) => {
3314                logdim!(
3315                    "kept {branch} and {} because the disposable slice could not be verified: {}",
3316                    dir.display(),
3317                    error.last_line()
3318                );
3319                return false;
3320            }
3321        }
3322        if let Err(error) =
3323            self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
3324        {
3325            logdim!(
3326                "kept {branch} because its exact disposable tip could not be deleted: {}",
3327                error.last_line()
3328            );
3329            return false;
3330        }
3331        match self.exact_ref_exists_checked(&self.root, &local_ref) {
3332            Ok(false) => {
3333                self.forget_branch(branch);
3334                true
3335            }
3336            Ok(true) => {
3337                logdim!("kept {branch} because its ref still exists after deletion");
3338                false
3339            }
3340            Err(error) => {
3341                logdim!(
3342                    "kept the branch record for {branch} because deletion could not be verified: {}",
3343                    error.last_line()
3344                );
3345                false
3346            }
3347        }
3348    }
3349
3350    pub fn release_review_worktree(&self, number: i64) {
3351        let path = self.worktree_path(&format!("review-{number}"));
3352        match self.review_ref_deletion_is_safe(number) {
3353            Ok(true) => {}
3354            Ok(false) => {
3355                logdim!(
3356                    "kept {} because no surviving ref preserves its review history",
3357                    path.display()
3358                );
3359                return;
3360            }
3361            Err(error) => {
3362                logdim!(
3363                    "kept {} because review history could not be verified: {}",
3364                    path.display(),
3365                    error.last_line()
3366                );
3367                return;
3368            }
3369        }
3370        match self.remove_worktree_at(&path) {
3371            Ok(true) => match self.delete_review_ref_if_safe(number) {
3372                Ok(true) => {}
3373                Ok(false) => logdim!(
3374                    "kept {} because its review history changed before deletion",
3375                    review_ref(number)
3376                ),
3377                Err(error) => logdim!(
3378                    "kept {} because deletion safety could not be rechecked: {}",
3379                    review_ref(number),
3380                    error.last_line()
3381                ),
3382            },
3383            Ok(false) => {}
3384            Err(error) => logdim!(
3385                "kept {} because removal did not reach a confirmed quiet point: {}",
3386                path.display(),
3387                error.last_line()
3388            ),
3389        }
3390    }
3391
3392    /// Release a read-only review checkout only when every observed part of
3393    /// its Git state still matches the checkpoint captured before the calls.
3394    pub(crate) fn release_review_worktree_checked(
3395        &self,
3396        number: i64,
3397        checkpoint: &WorktreeCheckpoint,
3398    ) -> Result<()> {
3399        let path = self.worktree_path(&format!("review-{number}"));
3400        self.require_unchanged_worktree(
3401            &path,
3402            checkpoint,
3403            &format!("review worktree for PR #{number}"),
3404        )?;
3405        if !self.review_ref_deletion_is_safe(number)? {
3406            bail!(
3407                "the review reference for PR #{number} has reflog-only recovery history. The \
3408                 worktree and reference were kept."
3409            );
3410        }
3411        if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
3412            bail!(
3413                "the verified review worktree at {} could not be removed, so its reference was \
3414                 kept",
3415                path.display()
3416            );
3417        }
3418        if !self.delete_review_ref_if_safe(number)? {
3419            bail!(
3420                "the review reference for PR #{number} changed before deletion. The reference was \
3421                 kept."
3422            );
3423        }
3424        Ok(())
3425    }
3426
3427    pub fn release_pr_worktree(&self, number: i64) -> bool {
3428        let path = self.worktree_path(&format!("pr-{number}"));
3429        let local = self.branch_for_pr(number);
3430        match self.branch_deletion_is_safe(&local) {
3431            Ok(true) => {}
3432            Ok(false) => {
3433                logdim!(
3434                    "kept {local} and {} because no surviving ref preserves its tip",
3435                    path.display()
3436                );
3437                return false;
3438            }
3439            Err(error) => {
3440                logdim!(
3441                    "kept {local} and {} because preservation could not be verified: {}",
3442                    path.display(),
3443                    error.last_line()
3444                );
3445                return false;
3446            }
3447        }
3448        match self.remove_worktree_at(&path) {
3449            Ok(true) => match self.delete_branch_if_safe(&local) {
3450                Ok(true) => {
3451                    self.forget_branch(&local);
3452                    true
3453                }
3454                Ok(false) => {
3455                    logdim!("kept {local} because its tip or reflog changed before deletion");
3456                    false
3457                }
3458                Err(error) => {
3459                    logdim!(
3460                        "kept {local} because deletion safety could not be rechecked: {}",
3461                        error.last_line()
3462                    );
3463                    false
3464                }
3465            },
3466            Ok(false) => false,
3467            Err(error) => {
3468                logdim!(
3469                    "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
3470                    path.display(),
3471                    error.last_line()
3472                );
3473                false
3474            }
3475        }
3476    }
3477
3478    // -- branch state -----------------------------------------------------
3479
3480    /// What to diff against: the remote tracking branch when it resolves, the
3481    /// local branch when it does not.
3482    ///
3483    /// This is not a nicety. Every "did the agent do anything" check hangs off
3484    /// this ref, and `git log` against a ref that does not exist fails silently
3485    /// and reads as "no commits". A checkout whose `origin/main` was never
3486    /// fetched would report every implementation as abandoned and throw the
3487    /// work away.
3488    pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
3489        let remote = format!("origin/{base}");
3490        if self.rev_exists(cwd, &remote) {
3491            return remote;
3492        }
3493        if self.rev_exists(cwd, base) {
3494            logdim!("origin/{base} does not resolve, comparing against local {base}");
3495            return base.to_string();
3496        }
3497        logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
3498        remote
3499    }
3500
3501    fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
3502        let spec = format!("{refname}^{{commit}}");
3503        !self
3504            .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
3505            .trim()
3506            .is_empty()
3507    }
3508
3509    pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
3510        let range = format!("{}..HEAD", self.base_ref(cwd, base));
3511        !self
3512            .git_try_at(Some(cwd), &["log", &range, "--oneline"])
3513            .trim()
3514            .is_empty()
3515    }
3516
3517    fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
3518        let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
3519        Ok(found.lines().any(|line| line.trim() == refname))
3520    }
3521
3522    fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
3523        let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
3524        count.trim().parse::<usize>().map_err(|e| {
3525            spar_err!(
3526                "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
3527                count.trim()
3528            )
3529        })
3530    }
3531
3532    pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
3533        let remote = format!("refs/remotes/origin/{base}");
3534        if self.exact_ref_exists_checked(cwd, &remote)? {
3535            return Ok(remote);
3536        }
3537        let local = format!("refs/heads/{base}");
3538        if self.exact_ref_exists_checked(cwd, &local)? {
3539            return Ok(local);
3540        }
3541        bail!("neither origin/{base} nor local branch {base} resolves")
3542    }
3543
3544    pub(crate) fn commit_count_checked(
3545        &self,
3546        cwd: &Path,
3547        refname: &str,
3548        base: &str,
3549    ) -> Result<usize> {
3550        let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
3551        let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
3552        count.trim().parse::<usize>().map_err(|e| {
3553            spar_err!(
3554                "git returned an invalid commit count for {range}: {:?} ({e})",
3555                count.trim()
3556            )
3557        })
3558    }
3559
3560    pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
3561        Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
3562    }
3563
3564    pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
3565        let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
3566        let head = head.trim().to_string();
3567        if head.is_empty() {
3568            bail!("git returned an empty HEAD for {}", cwd.display());
3569        }
3570        Ok(head)
3571    }
3572
3573    /// Whether a recorded SPAR branch's exact tip is retained by a pull request.
3574    ///
3575    /// Pull request head refs remain available after close or merge, so this is
3576    /// stronger than requiring an open pull request or a live remote branch.
3577    pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
3578        let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
3579        self.local_branch_is_preserved(branch.trim())
3580    }
3581
3582    /// Whether a recorded local branch's exact tip is retained by its pull
3583    /// request head, regardless of which branch is currently checked out.
3584    pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
3585        let known = self.known_branches();
3586        let Some(record) = known.get(branch) else {
3587            return Ok(false);
3588        };
3589        self.branch_is_preserved_checked(branch, record)
3590    }
3591
3592    /// Whether tracked, staged, or non-ignored untracked files are uncommitted.
3593    pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
3594        has_uncommitted_work(cwd)
3595    }
3596
3597    /// Whether removing a worktree would delete any local file Git does not
3598    /// reproduce from its commits, including ignored untracked files.
3599    fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
3600        repository_has_recoverable_work_with(cwd, true, disposable)
3601    }
3602
3603    /// Record ignored artifacts that existed before an editing call.
3604    pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
3605        let attributes = attribute_state(cwd)?;
3606        Ok(WorktreeBaseline {
3607            attributes,
3608            ignored_untracked: ignored_untracked_state(cwd)?,
3609            git_state: safe_git_state(cwd)?,
3610        })
3611    }
3612
3613    /// Capture the Git state of a checkout intended to remain read only while
3614    /// external commands inspect it.
3615    pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
3616        let attributes = attribute_state(cwd)?;
3617        Ok(WorktreeCheckpoint {
3618            path: std::fs::canonicalize(cwd)
3619                .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
3620            attributes,
3621            git_state: safe_git_state(cwd)?,
3622            ignored_untracked: ignored_untracked_state(cwd)?,
3623        })
3624    }
3625
3626    /// Move the files read-only calls created in a review checkout out of it,
3627    /// into `.spar/scratch/pr-<number>-<seconds>/` under the same paths, so the
3628    /// checkout matches its checkpoint again.
3629    ///
3630    /// A reviewer told to keep its scratch in the temporary directory still
3631    /// writes a repro test where the project's test runner will find it, and
3632    /// holding that against the review threw away everything both reviewers
3633    /// had done. What a review judges is the tracked tree, which
3634    /// `require_unchanged_worktree` still compares as strictly as ever, along
3635    /// with every untracked file that was there before. Only a file that was
3636    /// not is moved, and it is moved rather than deleted: it may be the
3637    /// reproduction a finding rests on.
3638    ///
3639    /// Best effort. Whatever this cannot move stays where it is, and the check
3640    /// that follows refuses it exactly as before.
3641    pub(crate) fn set_aside_review_scratch(
3642        &self,
3643        cwd: &Path,
3644        checkpoint: &WorktreeCheckpoint,
3645        number: i64,
3646    ) {
3647        let Ok(after) = probe_again_on_failure(|| ignored_untracked_state(cwd)) else {
3648            return;
3649        };
3650        let created: Vec<PathBuf> = checkpoint
3651            .ignored_untracked
3652            .new_ordinary_paths(&after)
3653            .into_iter()
3654            .filter(|path| {
3655                std::fs::symlink_metadata(cwd.join(path))
3656                    .is_ok_and(|meta| meta.is_file() || meta.file_type().is_symlink())
3657            })
3658            .collect();
3659        if created.is_empty() {
3660            return;
3661        }
3662        let shelf = match self.scratch_shelf(number) {
3663            Ok(shelf) => shelf,
3664            Err(e) => {
3665                logdim!("could not make room to move a reviewer's files aside: {e}");
3666                return;
3667            }
3668        };
3669        let mut moved = Vec::new();
3670        for path in created {
3671            let to = shelf.join(&path);
3672            let result = to
3673                .parent()
3674                .map_or(Ok(()), std::fs::create_dir_all)
3675                .and_then(|()| std::fs::rename(cwd.join(&path), &to));
3676            match result {
3677                Ok(()) => moved.push(path),
3678                Err(e) => logdim!(
3679                    "could not move {} out of the review checkout: {e}",
3680                    cwd.join(&path).display()
3681                ),
3682            }
3683        }
3684        if !moved.is_empty() {
3685            logwarn!(
3686                "PR #{number}: a reviewer left {} file(s) in the review checkout: {}. They were \
3687                 moved to {} and the review went on.",
3688                moved.len(),
3689                list_paths(&moved),
3690                shelf.display()
3691            );
3692        }
3693    }
3694
3695    /// A directory of its own under `.spar/scratch` for what one review phase
3696    /// left behind, so two phases never write over each other's files.
3697    fn scratch_shelf(&self, number: i64) -> Result<PathBuf> {
3698        let scratch = self.root.join(STATE_DIR).join("scratch");
3699        std::fs::create_dir_all(&scratch)
3700            .map_err(|e| spar_err!("could not create {}: {e}", scratch.display()))?;
3701        let stem = format!("pr-{number}-{}", crate::clock::now_secs());
3702        for serial in 0..1000 {
3703            let name = match serial {
3704                0 => stem.clone(),
3705                n => format!("{stem}-{n}"),
3706            };
3707            let shelf = scratch.join(name);
3708            match std::fs::create_dir(&shelf) {
3709                Ok(()) => return Ok(shelf),
3710                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
3711                Err(e) => return Err(spar_err!("could not create {}: {e}", shelf.display())),
3712            }
3713        }
3714        bail!(
3715            "could not choose a free directory under {}",
3716            scratch.display()
3717        )
3718    }
3719
3720    /// Require a read-only checkout to match a previously captured checkpoint.
3721    /// Any probe failure is an error because deletion cannot be proven safe.
3722    pub(crate) fn require_unchanged_worktree(
3723        &self,
3724        cwd: &Path,
3725        checkpoint: &WorktreeCheckpoint,
3726        label: &str,
3727    ) -> Result<()> {
3728        let resolved = std::fs::canonicalize(cwd).map_err(|e| {
3729            crate::error::SparError::uncertain_write(format!(
3730                "could not resolve the {label} at {} after inspection: {e}. It was kept.",
3731                cwd.display()
3732            ))
3733        })?;
3734        if resolved != checkpoint.path {
3735            return Err(uncertain_worktree_change(
3736                cwd,
3737                format!(
3738                    "the {label} moved from {} to {} during inspection. It was kept.",
3739                    checkpoint.path.display(),
3740                    resolved.display()
3741                ),
3742            ));
3743        }
3744        let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
3745            uncertain_worktree_change(
3746                cwd,
3747                format!(
3748                    "could not verify attribute files in the {label} at {}: {}. It was kept.",
3749                    cwd.display(),
3750                    e.last_line()
3751                ),
3752            )
3753        })?;
3754        if attributes != checkpoint.attributes {
3755            return Err(uncertain_worktree_change(
3756                cwd,
3757                format!(
3758                    "attribute files in the {label} at {} changed during inspection. It was \
3759                     kept for recovery.",
3760                    cwd.display()
3761                ),
3762            ));
3763        }
3764        let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
3765            uncertain_worktree_change(
3766                cwd,
3767                format!(
3768                    "could not verify the Git state of the {label} at {}: {}. It was kept.",
3769                    cwd.display(),
3770                    e.last_line()
3771                ),
3772            )
3773        })?;
3774        let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
3775            uncertain_worktree_change(
3776                cwd,
3777                format!(
3778                    "could not verify untracked files in the {label} at {}: {}. It was kept.",
3779                    cwd.display(),
3780                    e.last_line()
3781                ),
3782            )
3783        })?;
3784        let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
3785        // Said even when the inspection is accepted, for the same reason it is
3786        // said on an agent call: a path the project ignores is out of scope,
3787        // not unnoticed.
3788        note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
3789        let tracked = checkpoint.git_state.describe_difference(&git_state);
3790        let untracked = (!leavings.condemning.is_empty()).then(|| {
3791            format!(
3792                "untracked file(s) changed: {}",
3793                list_paths(&leavings.condemning)
3794            )
3795        });
3796        let Some(reason) = join_reasons(tracked, untracked) else {
3797            return Ok(());
3798        };
3799        Err(uncertain_worktree_change(
3800            cwd,
3801            format!(
3802                "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
3803                 for recovery.",
3804                cwd.display()
3805            ),
3806        ))
3807    }
3808
3809    /// Accept project-ignored files left by a successful editing call.
3810    /// Ordinary untracked files must already be represented by the commit.
3811    /// Acceptance does not authorize cleanup of the ignored files.
3812    pub(crate) fn refuse_new_ignored_files(
3813        &self,
3814        cwd: &Path,
3815        baseline: &WorktreeBaseline,
3816    ) -> Result<()> {
3817        self.allow_ignored_files(cwd, baseline).map(drop)
3818    }
3819
3820    /// Preserve unexpected output when the editing call did not finish.
3821    pub(crate) fn refuse_failed_edit_leavings(
3822        &self,
3823        cwd: &Path,
3824        baseline: &WorktreeBaseline,
3825    ) -> Result<()> {
3826        self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
3827            .map(drop)
3828    }
3829
3830    /// Reject ordinary untracked changes after a read-only call.
3831    pub(crate) fn refuse_read_only_leavings(
3832        &self,
3833        cwd: &Path,
3834        baseline: &WorktreeBaseline,
3835    ) -> Result<()> {
3836        self.refuse_changed_attributes(cwd, baseline)?;
3837        let after = ignored_untracked_state(cwd).map_err(|e| {
3838            uncertain_worktree_change(
3839                cwd,
3840                format!(
3841                    "could not verify untracked files in {} after a read-only call: {}. The \
3842                     worktree was kept for recovery.",
3843                    cwd.display(),
3844                    e.last_line()
3845                ),
3846            )
3847        })?;
3848        let leavings = baseline.ignored_untracked.read_only_leavings(&after);
3849        if leavings.condemning.is_empty() {
3850            return Ok(());
3851        }
3852        Err(uncertain_worktree_change(
3853            cwd,
3854            format!(
3855                "the read-only call left untracked file(s) in {} that the project does not \
3856                 ignore: {}. The worktree was kept for recovery.",
3857                cwd.display(),
3858                list_paths(&leavings.condemning)
3859            ),
3860        ))
3861    }
3862
3863    /// The ignored paths the check let through, for one report per attempt
3864    /// rather than one per check.
3865    fn allow_ignored_files(
3866        &self,
3867        cwd: &Path,
3868        baseline: &WorktreeBaseline,
3869    ) -> Result<AllowedArtifacts> {
3870        self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
3871    }
3872
3873    fn check_new_ignored_files(
3874        &self,
3875        cwd: &Path,
3876        baseline: &WorktreeBaseline,
3877        allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
3878    ) -> Result<AllowedArtifacts> {
3879        self.refuse_changed_attributes(cwd, baseline)?;
3880        let after = ignored_untracked_state(cwd).map_err(|e| {
3881            uncertain_worktree_change(
3882                cwd,
3883                format!(
3884                    "could not verify untracked files in {} after editing: {}. The worktree was \
3885                     kept for recovery.",
3886                    cwd.display(),
3887                    e.last_line()
3888                ),
3889            )
3890        })?;
3891        let changed = baseline.ignored_untracked.changed_paths(&after);
3892        if changed.is_empty() {
3893            return Ok(AllowedArtifacts::default());
3894        }
3895        let (ignored, changed): (Vec<_>, Vec<_>) = changed
3896            .into_iter()
3897            .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
3898        if changed.is_empty() {
3899            return Ok(AllowedArtifacts::split(ignored, &after));
3900        }
3901        let listed = list_paths(&changed);
3902        Err(uncertain_worktree_change(
3903            cwd,
3904            format!(
3905                "the editing call created or changed untracked or ignored file(s) in {} that \
3906                 cannot be represented by a managed commit: {listed}. The worktree was kept for \
3907                 recovery.",
3908                cwd.display()
3909            ),
3910        ))
3911    }
3912
3913    /// Existing untracked files belong to the checkout owner, even when a call
3914    /// also produces a valid tracked change. Refuse their modification or
3915    /// deletion before accepting the tracked result. Project-ignored files
3916    /// stay outside the commit, wherever the project puts them.
3917    pub(crate) fn refuse_changed_existing_untracked(
3918        &self,
3919        cwd: &Path,
3920        baseline: &WorktreeBaseline,
3921    ) -> Result<()> {
3922        self.check_changed_existing_untracked(cwd, baseline)
3923            .map(drop)
3924    }
3925
3926    fn allow_changed_ignored_artifacts(
3927        &self,
3928        cwd: &Path,
3929        baseline: &WorktreeBaseline,
3930    ) -> Result<AllowedArtifacts> {
3931        self.check_changed_existing_untracked(cwd, baseline)
3932    }
3933
3934    fn check_changed_existing_untracked(
3935        &self,
3936        cwd: &Path,
3937        baseline: &WorktreeBaseline,
3938    ) -> Result<AllowedArtifacts> {
3939        self.refuse_changed_attributes(cwd, baseline)?;
3940        let after = ignored_untracked_state(cwd).map_err(|e| {
3941            uncertain_worktree_change(
3942                cwd,
3943                format!(
3944                    "could not verify existing untracked files in {} after editing: {}. The \
3945                     worktree was kept for recovery.",
3946                    cwd.display(),
3947                    e.last_line()
3948                ),
3949            )
3950        })?;
3951        let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3952        if changed.is_empty() {
3953            return Ok(AllowedArtifacts::default());
3954        }
3955        let (ignored, changed): (Vec<_>, Vec<_>) = changed
3956            .into_iter()
3957            .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3958        if changed.is_empty() {
3959            return Ok(AllowedArtifacts::split(ignored, &after));
3960        }
3961        let listed = list_paths(&changed);
3962        Err(uncertain_worktree_change(
3963            cwd,
3964            format!(
3965                "the editing call changed or deleted existing untracked file(s) in {}: \
3966                 {listed}. The worktree was kept for recovery.",
3967                cwd.display()
3968            ),
3969        ))
3970    }
3971
3972    /// Refuse a byte or mode change that the index did not represent.
3973    ///
3974    /// Clean filters can normalize a working file back to its existing blob,
3975    /// and index flags can hide a change from porcelain status. Comparing the
3976    /// actual tracked files on both sides keeps those bytes from being treated
3977    /// as disposable just because Git has no diff for them.
3978    pub(crate) fn refuse_unrepresented_tracked_changes(
3979        &self,
3980        cwd: &Path,
3981        baseline: &WorktreeBaseline,
3982    ) -> Result<()> {
3983        self.refuse_changed_attributes(cwd, baseline)?;
3984        let after = safe_git_state(cwd).map_err(|e| {
3985            uncertain_worktree_change(
3986                cwd,
3987                format!(
3988                    "could not verify tracked files in {} after editing: {}. The worktree was \
3989                     kept for recovery.",
3990                    cwd.display(),
3991                    e.last_line()
3992                ),
3993            )
3994        })?;
3995        let mut changed = Vec::new();
3996        let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3997            uncertain_worktree_change(
3998                cwd,
3999                format!(
4000                    "could not record untracked files before verifying transformed content in {}: \
4001                     {}. The worktree was kept for recovery.",
4002                    cwd.display(),
4003                    e.last_line()
4004                ),
4005            )
4006        })?;
4007        let mut filter_was_run = false;
4008        let mut filter_problem = None;
4009        let mut repositories: BTreeSet<PathBuf> =
4010            baseline.git_state.repositories.keys().cloned().collect();
4011        repositories.extend(after.repositories.keys().cloned());
4012        'repositories: for repository_path in repositories {
4013            let before_repository = baseline.git_state.repositories.get(&repository_path);
4014            let after_repository = after.repositories.get(&repository_path);
4015            if before_repository.is_none() || after_repository.is_none() {
4016                changed.push(repository_path.clone());
4017                continue;
4018            }
4019            if before_repository.map(|repository| &repository.gitlinks)
4020                != after_repository.map(|repository| &repository.gitlinks)
4021            {
4022                changed.push(repository_path.join("<gitlinks>"));
4023            }
4024            let mut paths = BTreeSet::new();
4025            if let Some(repository) = before_repository {
4026                paths.extend(repository.tracked.keys().cloned());
4027            }
4028            if let Some(repository) = after_repository {
4029                paths.extend(repository.tracked.keys().cloned());
4030            }
4031            for path in paths {
4032                let before = before_repository.and_then(|repository| repository.tracked.get(&path));
4033                let current = after_repository.and_then(|repository| repository.tracked.get(&path));
4034                let worktree_changed =
4035                    before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
4036                let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
4037                    != current.map(|entry| (&entry.index_mode, &entry.index_oid));
4038                if !worktree_changed {
4039                    continue;
4040                }
4041                if !index_changed {
4042                    changed.push(repository_path.join(&path));
4043                    continue;
4044                }
4045                let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
4046                let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
4047                let Some(current_entry) = current else {
4048                    continue;
4049                };
4050                let Some(current_worktree) = current_worktree else {
4051                    continue;
4052                };
4053                let content_changed =
4054                    before_worktree.map(|file| file.content) != Some(current_worktree.content);
4055                let mode_changed = before_worktree.map(|file| file.mode.as_str())
4056                    != Some(current_worktree.mode.as_str());
4057                let repository = cwd.join(&repository_path);
4058                let represented_content = if content_changed {
4059                    filter_was_run = true;
4060                    let result =
4061                        filtered_index_content(&repository, &path, &current_entry.index_oid);
4062                    self.refuse_changed_attributes(cwd, baseline)?;
4063                    match result {
4064                        Ok(expected) => expected == current_worktree.content,
4065                        Err(error) => {
4066                            filter_problem = Some(format!(
4067                                "could not verify transformed content for {:?}: {}",
4068                                repository_path.join(&path),
4069                                error.last_line()
4070                            ));
4071                            false
4072                        }
4073                    }
4074                } else {
4075                    true
4076                };
4077                let represented_mode =
4078                    !mode_changed || current_worktree.mode == current_entry.index_mode;
4079                if !represented_content || !represented_mode {
4080                    changed.push(repository_path.join(&path));
4081                }
4082                if filter_problem.is_some() {
4083                    break 'repositories;
4084                }
4085            }
4086        }
4087        if filter_was_run {
4088            self.refuse_changed_attributes(cwd, baseline)?;
4089            let verified = safe_git_state(cwd).map_err(|e| {
4090                uncertain_worktree_change(
4091                    cwd,
4092                    format!(
4093                        "could not recheck tracked files after verifying transformed content in \
4094                         {}: {}. The worktree was kept for recovery.",
4095                        cwd.display(),
4096                        e.last_line()
4097                    ),
4098                )
4099            })?;
4100            let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
4101                uncertain_worktree_change(
4102                    cwd,
4103                    format!(
4104                        "could not recheck untracked files after verifying transformed content \
4105                         in {}: {}. The worktree was kept for recovery.",
4106                        cwd.display(),
4107                        e.last_line()
4108                    ),
4109                )
4110            })?;
4111            if verified != after
4112                || before_filter_untracked.changed_beyond_generated(&verified_untracked)
4113            {
4114                return Err(uncertain_worktree_change(
4115                    cwd,
4116                    "a content filter changed the worktree while SPAR verified the managed \
4117                     commit. The worktree was kept for recovery.",
4118                ));
4119            }
4120            self.refuse_changed_existing_untracked(cwd, baseline)?;
4121        }
4122        if let Some(problem) = filter_problem {
4123            return Err(uncertain_worktree_change(
4124                cwd,
4125                format!("{problem}. The worktree was kept for recovery."),
4126            ));
4127        }
4128        if changed.is_empty() {
4129            return Ok(());
4130        }
4131        let listed = list_paths(&changed);
4132        Err(uncertain_worktree_change(
4133            cwd,
4134            format!(
4135                "the editing call changed tracked working-file bytes, modes, repositories, or \
4136                 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
4137                 recovery."
4138            ),
4139        ))
4140    }
4141
4142    pub(crate) fn refuse_changed_attributes(
4143        &self,
4144        cwd: &Path,
4145        baseline: &WorktreeBaseline,
4146    ) -> Result<()> {
4147        let after = attribute_state(cwd).map_err(|e| {
4148            uncertain_worktree_change(
4149                cwd,
4150                format!(
4151                    "could not verify attribute files in {} after editing: {}. The worktree was \
4152                     kept for recovery.",
4153                    cwd.display(),
4154                    e.last_line()
4155                ),
4156            )
4157        })?;
4158        if after == baseline.attributes {
4159            return Ok(());
4160        }
4161        Err(uncertain_worktree_change(
4162            cwd,
4163            format!(
4164                "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
4165                 refused to run a Git operation that could select a new external filter.",
4166                cwd.display()
4167            ),
4168        ))
4169    }
4170
4171    /// Commit a successful editing call from the trusted harness process.
4172    ///
4173    /// Editing sandboxes only need the working tree. They never need writable
4174    /// access to the repository's object database, refs, config, or hooks.
4175    pub(crate) fn commit_pending_changes(
4176        &self,
4177        cwd: &Path,
4178        baseline: &WorktreeBaseline,
4179        preferred_subject: &str,
4180        fallback_subject: &str,
4181    ) -> Result<bool> {
4182        let mut artifacts = IgnoredArtifacts::default();
4183        self.refuse_changed_attributes(cwd, baseline)?;
4184        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
4185        refuse_unsafe_index_flags(cwd)?;
4186        if !self.has_uncommitted_changes(cwd)? {
4187            artifacts.left(self.allow_ignored_files(cwd, baseline)?);
4188            artifacts.report(cwd);
4189            return Ok(false);
4190        }
4191        self.stage_managed_changes(cwd, baseline).map_err(|e| {
4192            e.with_message(format!(
4193                "could not stage changes in {}: {}",
4194                cwd.display(),
4195                e.last_line()
4196            ))
4197        })?;
4198        // Git ignore rules decide which files stay outside the commit.
4199        // Whether cleanup can delete them is a separate decision.
4200        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
4201        let changed_gitlinks = changed_staged_gitlinks(cwd)?;
4202        if !changed_gitlinks.is_empty() {
4203            let listed = list_paths(&changed_gitlinks);
4204            bail!(
4205                "the editing call added or changed a gitlink at {listed}. It was staged but not \
4206                 committed because the referenced repository objects might exist only inside \
4207                 this worktree. The worktree was kept for recovery."
4208            );
4209        }
4210        let mut subject = self.clean_title(preferred_subject)?;
4211        if subject.trim().is_empty() {
4212            subject = self.clean_title(fallback_subject)?;
4213        }
4214        self.commit_staged_changes(cwd, &subject).map_err(|e| {
4215            e.with_message(format!(
4216                "could not commit changes in {}: {}. The staged files were kept.",
4217                cwd.display(),
4218                e.last_line()
4219            ))
4220        })?;
4221        if has_tracked_or_staged_work(cwd)? {
4222            bail!(
4223                "the commit in {} left additional uncommitted files. They were kept for \
4224                 recovery.",
4225                cwd.display()
4226            );
4227        }
4228        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
4229        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
4230        artifacts.report(cwd);
4231        Ok(true)
4232    }
4233
4234    fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
4235        let after = ignored_untracked_state(cwd)?;
4236        self.git_at_without_automation(cwd, &["add", "-u"])?;
4237        let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
4238        if paths.is_empty() {
4239            return Ok(());
4240        }
4241        let mut input = Vec::new();
4242        for path in paths {
4243            input.extend(os_str_bytes(path.as_os_str())?);
4244            input.push(0);
4245        }
4246        let argv = git_without_automation_argv(&[
4247            "--literal-pathspecs",
4248            "add",
4249            "--pathspec-from-file=-",
4250            "--pathspec-file-nul",
4251        ]);
4252        proc::run_with_input_bytes(
4253            &argv,
4254            &self.git_opts(Some(cwd), true).stop_descendants(true),
4255            &input,
4256        )?;
4257        Ok(())
4258    }
4259
4260    /// Commit an index prepared by the parent without signing, hooks, or
4261    /// inherited repository automation.
4262    pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
4263        self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
4264            .map(|_| ())
4265    }
4266
4267    /// How many commits `refname` carries that the base does not.
4268    ///
4269    /// Counted from the commits themselves rather than from `commit_subjects`,
4270    /// which drops a commit whose message is empty. The guards in
4271    /// `worktree_add` decide whether to delete a branch on this number, and an
4272    /// empty message must not read as an empty branch.
4273    pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
4274        let range = format!("{}..{refname}", self.base_ref(cwd, base));
4275        self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
4276            .trim()
4277            .parse()
4278            .unwrap_or(0)
4279    }
4280
4281    /// One `hash subject` line per commit `refname` carries that the base does
4282    /// not, oldest first. For showing a person what is on a branch, so the
4283    /// hash keeps a commit with no message from listing as nothing.
4284    pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
4285        let range = format!("{}..{refname}", self.base_ref(cwd, base));
4286        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
4287            .lines()
4288            .map(str::to_string)
4289            .collect()
4290    }
4291
4292    /// The commits `later` carries that `earlier` does not, oldest first, when
4293    /// `earlier` is genuinely behind it.
4294    ///
4295    /// `None` when it is not an ancestor, which is not the same as nothing
4296    /// having landed. `rewrite_commits_if_needed` rewrites hashes from the first
4297    /// offending commit spar made onward, so a head recorded part way through a
4298    /// round can still be a readable object and no longer be on the branch. `git log` answers that
4299    /// with every commit on the branch, so without the check the one caller
4300    /// would report the whole branch as unread, which is the widest possible
4301    /// wrong answer.
4302    ///
4303    /// No `base_ref` resolution, unlike its neighbours: these are commits rather
4304    /// than branch names, and putting a sha through it logs a fallback line
4305    /// every time.
4306    pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
4307        let ancestor = self
4308            .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
4309            .is_ok();
4310        if !ancestor {
4311            return None;
4312        }
4313        let range = format!("{earlier}..{later}");
4314        Some(
4315            self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
4316                .lines()
4317                .map(str::to_string)
4318                .collect(),
4319        )
4320    }
4321
4322    /// The subjects of the commits `refname` carries that the base does not,
4323    /// oldest first.
4324    pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
4325        let range = format!("{}..{refname}", self.base_ref(cwd, base));
4326        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
4327            .lines()
4328            .map(str::trim)
4329            .filter(|line| !line.is_empty())
4330            .map(str::to_string)
4331            .collect()
4332    }
4333
4334    /// The paths this checkout changes relative to the base, sorted.
4335    ///
4336    /// A three dot range, matching `diff_stat`: what the branch did, not what
4337    /// the base has done since.
4338    ///
4339    /// `--no-renames` because a rename reported as its destination alone leaves
4340    /// the source out of the list, and a part carrying only the destination
4341    /// would be a copy. As a deletion and an addition it is two paths, which a
4342    /// part can carry together or leave to the leftover report.
4343    ///
4344    /// `-z` because without it git writes paths for display: anything
4345    /// non-ASCII comes back escaped and wrapped in quotes, and that string is
4346    /// not a path. A part built from one carries a pathspec matching no file,
4347    /// so the file never reaches the slice while every list still says the part
4348    /// took it. It also keeps a path with a space at either end intact.
4349    pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
4350        let range = format!("{}...HEAD", self.base_ref(cwd, base));
4351        self.git_try_at(
4352            Some(cwd),
4353            &["diff", "--name-only", "--no-renames", "-z", &range],
4354        )
4355        .split('\0')
4356        .filter(|path| !path.is_empty())
4357        .map(str::to_string)
4358        .collect()
4359    }
4360
4361    /// The lines a line comment can go on: the new side of each hunk of the
4362    /// branch's three dot diff, which is the diff GitHub shows for a pull
4363    /// request.
4364    ///
4365    /// Every flag that shapes the hunks is spelled out as git's own default,
4366    /// which is what GitHub shows, so a user's `diff.noprefix`, `diff.context`,
4367    /// `diff.interHunkContext`, `diff.algorithm` or `diff.submodule` cannot
4368    /// move a line comment onto a line GitHub does not show. `GIT_DIFF_OPTS`
4369    /// is emptied for the same reason: git reads it after the command line,
4370    /// so it would widen every hunk past `--unified=3`. `--no-ext-diff` and
4371    /// `--no-textconv` also matter for more than the shape: the branch being
4372    /// reviewed is somebody else's, and a diff driver or text converter would
4373    /// run a script it changed. A failure is no hunks, which only means every
4374    /// finding stays in the summary.
4375    pub fn diff_hunks(&self, cwd: &Path, base: &str) -> Hunks {
4376        let range = format!("{}...HEAD", self.base_ref(cwd, base));
4377        let argv = git_without_maintenance_argv(&[
4378            "-c",
4379            "core.quotePath=false",
4380            "diff",
4381            "--no-color",
4382            "--no-ext-diff",
4383            "--no-textconv",
4384            "--unified=3",
4385            "--inter-hunk-context=0",
4386            "--diff-algorithm=myers",
4387            "--indent-heuristic",
4388            "--submodule=short",
4389            "--find-renames",
4390            "--src-prefix=a/",
4391            "--dst-prefix=b/",
4392            &range,
4393        ]);
4394        let opts = self.git_opts(Some(cwd), false).env("GIT_DIFF_OPTS", "");
4395        new_side_hunks(&proc::run(&argv, &opts).unwrap_or_default())
4396    }
4397
4398    /// The files one commit range touched, for checking a claim against the
4399    /// diff rather than against the report that made it.
4400    pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
4401        self.git_try_at(
4402            Some(cwd),
4403            &[
4404                "diff",
4405                "--name-only",
4406                "--no-renames",
4407                "-z",
4408                &format!("{from}..{to}"),
4409            ],
4410        )
4411        .split('\0')
4412        .filter(|path| !path.is_empty())
4413        .map(str::to_string)
4414        .collect()
4415    }
4416
4417    /// Where `refname` left the base: the commit its own change is measured
4418    /// from, and the one a slice of that change has to be taken against.
4419    pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
4420        let base_ref = self.base_ref(cwd, base);
4421        let out = self
4422            .git_at(Some(cwd), &["merge-base", &base_ref, refname])
4423            .map_err(|e| {
4424                spar_err!(
4425                    "could not find where {refname} and {base_ref} diverged. {}",
4426                    e.last_line()
4427                )
4428            })?;
4429        let sha = out.trim().to_string();
4430        if sha.is_empty() {
4431            bail!("{refname} and {base_ref} share no history");
4432        }
4433        Ok(sha)
4434    }
4435
4436    pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
4437        let range = format!("{}...HEAD", self.base_ref(cwd, base));
4438        let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
4439        full.trim().to_string()
4440    }
4441
4442    /// Scrub commit messages that slipped past the prompt.
4443    ///
4444    /// `git filter-branch` calls back into this same binary, so there is no
4445    /// interpreter to find and no second copy of the rules to drift.
4446    ///
4447    /// `floor` is the head spar found when it started, and nothing at or below
4448    /// it is rewritten. The style gate is for what spar writes, and a person's
4449    /// commit message is not that: rewriting one changes a published SHA, which
4450    /// diverges the branch under somebody's own checkout and is exactly the
4451    /// history rewrite this design promises never to do. A `None` floor means
4452    /// every commit above the base is spar's own, which is true of a branch
4453    /// spar created in this invocation and of nothing else.
4454    pub fn rewrite_commits_if_needed(
4455        &self,
4456        cwd: &Path,
4457        base: &str,
4458        floor: Option<&str>,
4459    ) -> Result<()> {
4460        let base_ref = self.base_ref(cwd, base);
4461        let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
4462        self.report_commits_left_alone(cwd, &base_ref, floor);
4463
4464        let range = format!("{floor}..HEAD");
4465        let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
4466
4467        let offenders = raw
4468            .split('\x1e')
4469            .filter_map(|entry| entry.split_once('\0'))
4470            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4471            .count();
4472        if offenders == 0 {
4473            return Ok(());
4474        }
4475        logdim!("{offenders} commit message(s) violated style rules, rewriting");
4476
4477        let exe = self_binary()?;
4478        let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
4479
4480        let argv: Vec<String> = [
4481            "git",
4482            "filter-branch",
4483            "-f",
4484            "--msg-filter",
4485            &filter,
4486            &range,
4487        ]
4488        .iter()
4489        .map(|s| s.to_string())
4490        .collect();
4491        let opts = ExecOpts::new()
4492            .cwd(cwd)
4493            .check(false)
4494            .timeout_secs(600)
4495            .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
4496            .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
4497            .env(
4498                "SPAR_BAN_AI_ATTRIBUTION",
4499                bool_env(self.style.ban_ai_attribution),
4500            );
4501        let _ = proc::run(&argv, &opts);
4502
4503        let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
4504        if !style::violations(&after, &self.style).is_empty() {
4505            bail!(
4506                "commit messages still violate style rules after a rewrite in {}.",
4507                cwd.display()
4508            );
4509        }
4510        Ok(())
4511    }
4512
4513    /// Say out loud when a message spar will not touch breaks the style rules.
4514    ///
4515    /// Silence here would read as "there was nothing to scrub", and the point
4516    /// of the floor is that there was something and spar chose to leave it.
4517    fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
4518        if floor == base_ref {
4519            return;
4520        }
4521        let raw = self.git_try_at(
4522            Some(cwd),
4523            &[
4524                "log",
4525                &format!("{base_ref}..{floor}"),
4526                "--format=%H%x00%B%x1e",
4527            ],
4528        );
4529        let left: Vec<&str> = raw
4530            .split('\x1e')
4531            .filter_map(|entry| entry.split_once('\0'))
4532            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4533            .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
4534            .collect();
4535        if left.is_empty() {
4536            return;
4537        }
4538        logdim!(
4539            "{} commit message(s) already on the branch break the style rules and were left \
4540             alone: {}. spar does not rewrite commits it did not write.",
4541            left.len(),
4542            left.join(", ")
4543        );
4544    }
4545
4546    /// Push by explicit refspec from HEAD, leasing the head spar reviewed.
4547    ///
4548    /// A resumed PR is checked out under a local name (`pr-N`) that does not
4549    /// match its remote branch, so pushing by branch name would resolve the
4550    /// wrong local ref or fail outright.
4551    ///
4552    /// `expected` is the remote head spar last read. A bare `--force-with-lease`
4553    /// leases the implicit tracking ref instead, which is shared across every
4554    /// worktree and is updated by any `git fetch`. The editing agent is a real
4555    /// CLI with shell access, and fetching is a normal thing for it to do: one
4556    /// `git fetch` mid round moves the tracking ref to whatever the remote holds
4557    /// and the lease then passes against a head spar never reviewed, which
4558    /// silently drops somebody's commit. Naming the head makes the push refuse
4559    /// exactly that case.
4560    pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
4561        self.push_to(cwd, &PushTarget::Origin, branch, expected)
4562    }
4563
4564    /// `push`, to wherever the branch was first published.
4565    pub fn push_to(
4566        &self,
4567        cwd: &Path,
4568        target: &PushTarget,
4569        branch: &str,
4570        expected: Option<&str>,
4571    ) -> Result<()> {
4572        let expected = expected.map(str::trim).filter(|head| !head.is_empty());
4573        if let Some(head) = expected {
4574            self.refuse_push_that_drops(cwd, head)?;
4575        }
4576        let pushed = self
4577            .git_push(cwd, target, branch, expected)
4578            .map_err(|e| push_error(target, branch, &e));
4579        self.record_write(pushed)
4580    }
4581
4582    /// Publish a branch created in this invocation, from a fork if origin
4583    /// refuses it.
4584    ///
4585    /// Only a new branch can move. A pull request that already exists has its
4586    /// branch where it is, and pushing it anywhere else would not update it. A
4587    /// new one has no pull request yet, so a fork is just where it is opened
4588    /// from, which is how anybody without write access contributes.
4589    ///
4590    /// One write whichever way it goes. The refusal is not counted as a
4591    /// failure when the fork takes the branch: the run did what it set out to,
4592    /// and a failed write makes the exit status say otherwise.
4593    pub fn publish_new_branch(&self, cwd: &Path, branch: &str, base: &str) -> Result<PushTarget> {
4594        let origin = PushTarget::Origin;
4595        let refused = match self.git_push(cwd, &origin, branch, None) {
4596            Ok(()) => return self.record_write(Ok(origin)),
4597            Err(e) if self.fork_when_denied && push_denial(&e.to_string()).is_some() => e,
4598            Err(e) => {
4599                let err = push_error(&origin, branch, &e);
4600                let err = if push_denial(&e.to_string()).is_some() {
4601                    spar_err!(
4602                        "{err} Set fork_when_denied = true under [loop] to publish it from a fork \
4603                         instead."
4604                    )
4605                } else {
4606                    err
4607                };
4608                return self.record_write(Err(err));
4609            }
4610        };
4611        self.record_write(self.publish_on_fork(cwd, branch, base, &refused))
4612    }
4613
4614    fn publish_on_fork(
4615        &self,
4616        cwd: &Path,
4617        branch: &str,
4618        base: &str,
4619        refused: &crate::error::SparError,
4620    ) -> Result<PushTarget> {
4621        let fork = self.fork().map_err(|e| {
4622            spar_err!(
4623                "{} spar could not publish it from a fork instead: {}",
4624                push_error(&PushTarget::Origin, branch, refused),
4625                e.last_line()
4626            )
4627        })?;
4628        let lease = self.fork_branch_lease(&fork, branch, base)?;
4629        let refused = refused.to_string();
4630        log!(
4631            "{} Publishing {branch} from the fork {} instead.",
4632            push_denial(&refused).unwrap_or("origin refused the push."),
4633            fork.full_name
4634        );
4635        let target = PushTarget::Fork(fork);
4636        // Not `push_to`: a lease on a branch a closed pull request left behind
4637        // replaces it, and that branch was never this HEAD's to contain.
4638        self.git_push(cwd, &target, branch, lease.as_deref())
4639            .map_err(|e| push_error(&target, branch, &e))?;
4640        Ok(target)
4641    }
4642
4643    /// What the first push of `branch` to the fork leases against.
4644    ///
4645    /// Nothing when the fork has no such branch. Its tip when a pull request
4646    /// already holds every commit on it beyond `base`, which is what a closed
4647    /// pull request leaves, since GitHub keeps the branch on the fork. Anything
4648    /// else is work only that branch has, and is refused the way an issue
4649    /// branch on origin is.
4650    fn fork_branch_lease(&self, fork: &Fork, branch: &str, base: &str) -> Result<Option<String>> {
4651        let live_ref = format!("refs/heads/{branch}");
4652        let unreadable = |e: crate::error::SparError| {
4653            spar_err!(
4654                "could not check whether the fork {} already has {branch}. {}",
4655                fork.full_name,
4656                e.last_line()
4657            )
4658        };
4659        let listed = self
4660            .git(&["ls-remote", "--heads", &fork.url, &live_ref])
4661            .map_err(unreadable)?;
4662        let Some(tip) = remote_head_oid(&listed, &live_ref)? else {
4663            return Ok(None);
4664        };
4665        let local = format!("refs/spar/fork/{branch}");
4666        self.git(&[
4667            "fetch",
4668            "--no-tags",
4669            &fork.url,
4670            &format!("+{live_ref}:{local}"),
4671        ])
4672        .map_err(unreadable)?;
4673        let ahead = self.commit_count_checked(&self.root, &local, base);
4674        let held = matches!(ahead, Ok(0)) || self.pull_request_holds(branch, &local, base);
4675        self.git_try(&["update-ref", "-d", &local]);
4676        let ahead = ahead?;
4677        if !held {
4678            bail!(
4679                "the fork {} already has {branch} with {ahead} commit(s) that are not on {base}, \
4680                 and no pull request accounts for them. Publishing over it would lose that work.\n\
4681                 Delete it with `git push {} --delete {branch}` if it is no longer needed, then \
4682                 run spar again.",
4683                fork.full_name,
4684                fork.url
4685            );
4686        }
4687        Ok(Some(tip))
4688    }
4689
4690    fn git_push(
4691        &self,
4692        cwd: &Path,
4693        target: &PushTarget,
4694        branch: &str,
4695        expected: Option<&str>,
4696    ) -> Result<()> {
4697        let refspec = format!("HEAD:refs/heads/{branch}");
4698        let lease = match (expected, target) {
4699            (Some(head), _) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
4700            // A fork is pushed to by URL, so there is no tracking ref for a
4701            // bare lease to read. Leasing against nothing makes the first push
4702            // a create that never moves a branch already there.
4703            (None, PushTarget::Fork(_)) => format!("--force-with-lease=refs/heads/{branch}:"),
4704            (None, PushTarget::Origin) => "--force-with-lease".to_string(),
4705        };
4706        self.push_retrying(
4707            cwd,
4708            target.remote(),
4709            branch,
4710            &target.describe(branch),
4711            &["push", &lease, target.remote(), &refspec],
4712        )
4713    }
4714
4715    /// `git push` with `args`, tried again when the remote fails on its own
4716    /// side. See `retry_transient_push`.
4717    fn push_retrying(
4718        &self,
4719        cwd: &Path,
4720        remote: &str,
4721        branch: &str,
4722        label: &str,
4723        args: &[&str],
4724    ) -> Result<()> {
4725        retry_transient_push(
4726            label,
4727            || self.git_at(Some(cwd), args).map(|_| ()),
4728            || self.remote_branch_is_head(cwd, remote, branch),
4729            &PUSH_RETRY_WAITS,
4730            proc::sleep_unless_interrupted,
4731        )
4732    }
4733
4734    /// Whether `remote` has `branch` at the HEAD of `cwd`. False when either
4735    /// side cannot be read, which leaves the push to be tried again.
4736    fn remote_branch_is_head(&self, cwd: &Path, remote: &str, branch: &str) -> bool {
4737        let remote_ref = format!("refs/heads/{branch}");
4738        let (Ok(head), Ok(listed)) = (
4739            self.git_at(Some(cwd), &["rev-parse", "HEAD"]),
4740            self.git_at(Some(cwd), &["ls-remote", "--heads", remote, &remote_ref]),
4741        ) else {
4742            return false;
4743        };
4744        matches!(remote_head_oid(&listed, &remote_ref), Ok(Some(oid)) if oid == head.trim())
4745    }
4746
4747    /// The authenticated user's fork of this repository, made if there is none.
4748    ///
4749    /// GitHub answers a request to fork with the fork that already exists, so
4750    /// asking is safe either way, but it is a write and it is only made when
4751    /// looking found nothing. A new fork is copied in the background, so it is
4752    /// waited for before anything is pushed to it.
4753    fn fork(&self) -> Result<Fork> {
4754        match self.existing_fork() {
4755            Ok(Some(fork)) => return Ok(fork),
4756            Ok(None) => {}
4757            Err(e) => logdim!("{e} Asking GitHub to fork it, which returns any fork there is."),
4758        }
4759        let answer = self
4760            .gh(&["api", "-X", "POST", "repos/{owner}/{repo}/forks"])
4761            .map_err(|e| spar_err!("could not fork this repository. {}", e.last_line()))?;
4762        let fork = fork_from_api(&answer, &self.origin_push_url())?;
4763        self.wait_for_fork(&fork)?;
4764        Ok(self.fork.get_or_init(|| fork).clone())
4765    }
4766
4767    /// The authenticated user's fork of this repository, if there is one,
4768    /// found without making one.
4769    fn existing_fork(&self) -> Result<Option<Fork>> {
4770        if let Some(fork) = self.fork.get() {
4771            return Ok(Some(fork.clone()));
4772        }
4773        let answer = self
4774            .gh(&[
4775                "api",
4776                "graphql",
4777                "-F",
4778                "owner={owner}",
4779                "-F",
4780                "name={repo}",
4781                "-f",
4782                "query=query($owner: String!, $name: String!) { repository(owner: $owner, name: \
4783                 $name) { forks(first: 1, affiliations: [OWNER]) { nodes { nameWithOwner owner { \
4784                 login } sshUrl url } } } }",
4785                "--jq",
4786                ".data.repository.forks.nodes",
4787            ])
4788            .map_err(|e| {
4789                spar_err!(
4790                    "could not look for a fork of this repository. {}",
4791                    e.last_line()
4792                )
4793            })?;
4794        let found = fork_from_listing(&answer, &self.origin_push_url())?;
4795        Ok(found.map(|fork| self.fork.get_or_init(|| fork).clone()))
4796    }
4797
4798    /// Whether GitHub says the authenticated user cannot push here. False
4799    /// when it cannot say, so an unanswered question skips the early check
4800    /// rather than guessing.
4801    fn origin_refuses_push(&self) -> bool {
4802        self.gh_try(&["api", "repos/{owner}/{repo}", "--jq", ".permissions.push"])
4803            .trim()
4804            == "false"
4805    }
4806
4807    fn origin_push_url(&self) -> String {
4808        self.git_try(&["remote", "get-url", "--push", "origin"])
4809    }
4810
4811    /// Where spar can push a pull request's branch: origin for a branch in
4812    /// this repository, the fork for one on the authenticated user's own fork,
4813    /// such as one spar opened after origin refused it, and nowhere for
4814    /// anybody else's.
4815    pub fn pr_push_target(&self, pr: &PrView) -> Option<PushTarget> {
4816        if !pr.is_cross_repository {
4817            return Some(PushTarget::Origin);
4818        }
4819        let viewer = self.viewer_login().ok()?;
4820        own_fork_target(pr, viewer, &self.origin_push_url())
4821    }
4822
4823    fn wait_for_fork(&self, fork: &Fork) -> Result<()> {
4824        const TRIES: u32 = 30;
4825        let mut tries = 1;
4826        loop {
4827            match self.git(&["ls-remote", &fork.url, "HEAD"]) {
4828                Ok(_) => return Ok(()),
4829                Err(e) if tries >= TRIES => bail!(
4830                    "the fork {} could not be reached after {} seconds. {}",
4831                    fork.full_name,
4832                    TRIES * 2,
4833                    e.last_line()
4834                ),
4835                Err(_) => {
4836                    tries += 1;
4837                    std::thread::sleep(std::time::Duration::from_secs(2));
4838                }
4839            }
4840        }
4841    }
4842
4843    /// Refuse a push whose HEAD does not contain the head spar reviewed.
4844    ///
4845    /// The lease answers the remote moving. This answers the local side moving:
4846    /// an agent that reset or rebased the worktree leaves a HEAD that would
4847    /// publish over the reviewed head rather than on top of it, and the lease
4848    /// cannot tell the difference because the remote is exactly where spar left
4849    /// it.
4850    fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
4851        let head = self.head_oid_checked(cwd)?;
4852        if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
4853            return Ok(());
4854        }
4855        bail!(
4856            "refusing to push {head} because it does not contain the reviewed head {expected}. \
4857             The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
4858             the intended commits on top of it before resuming.",
4859            cwd.display()
4860        )
4861    }
4862
4863    /// Create one remote branch for a split without ever moving an existing ref.
4864    ///
4865    /// `worktree_for_split` chooses a name that is free locally and on origin,
4866    /// but another writer can still take it before the push. An empty expected
4867    /// value in the lease makes this an atomic create: it creates an absent ref,
4868    /// accepts an identical ref as a no-op, and never moves an existing ref.
4869    /// The shared `push` method cannot be used because its lease permits
4870    /// updating a ref fetched earlier.
4871    pub fn push_split_branch(
4872        &self,
4873        cwd: &Path,
4874        branch: &str,
4875    ) -> std::result::Result<(), SplitPushError> {
4876        let remote_ref = format!("refs/heads/{branch}");
4877        let lease = format!("--force-with-lease={remote_ref}:");
4878        let refspec = format!("HEAD:{remote_ref}");
4879        let pushed = self.push_retrying(
4880            cwd,
4881            "origin",
4882            branch,
4883            &format!("origin/{branch}"),
4884            &["push", &lease, "origin", &refspec],
4885        );
4886        let result = match pushed {
4887            Ok(()) => Ok(()),
4888            Err(push_error) => {
4889                let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
4890                let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
4891                reconcile_failed_split_push(branch, push_error, local, remote)
4892            }
4893        };
4894        self.record_write(result)
4895    }
4896
4897    // -- gh ---------------------------------------------------------------
4898
4899    pub fn gh(&self, args: &[&str]) -> Result<String> {
4900        self.gh_at(None, args)
4901    }
4902
4903    pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
4904        let mut argv = vec!["gh".to_string()];
4905        argv.extend(args.iter().map(|s| s.to_string()));
4906        proc::run(
4907            &argv,
4908            &ExecOpts::new()
4909                .cwd(cwd.unwrap_or(&self.root))
4910                .timeout_secs(300),
4911        )
4912    }
4913
4914    /// Run gh with something on its stdin.
4915    ///
4916    /// A tracker body is far too long to pass on argv, and `--body-file -` is
4917    /// how gh takes one. `proc::exec` already wires the pipe, so this is a
4918    /// sibling of `gh_at` rather than anything new.
4919    pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
4920        let mut argv = vec!["gh".to_string()];
4921        argv.extend(args.iter().map(|s| s.to_string()));
4922        proc::run(
4923            &argv,
4924            &ExecOpts::new()
4925                .cwd(&self.root)
4926                .timeout_secs(300)
4927                .stdin(stdin),
4928        )
4929    }
4930
4931    pub fn gh_try(&self, args: &[&str]) -> String {
4932        let mut argv = vec!["gh".to_string()];
4933        argv.extend(args.iter().map(|s| s.to_string()));
4934        proc::run(
4935            &argv,
4936            &ExecOpts::new()
4937                .cwd(&self.root)
4938                .check(false)
4939                .timeout_secs(300),
4940        )
4941        .unwrap_or_default()
4942    }
4943
4944    /// `gh api -i`: the status line and headers with the body, and the exit
4945    /// code ignored on purpose. A `304 Not Modified`, which is the answer a
4946    /// conditional poll wants, makes gh exit 1 while printing exactly what is
4947    /// needed. Only a spawn failure or a timeout is an error here.
4948    pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
4949        let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
4950        for (name, value) in headers {
4951            argv.push("-H".to_string());
4952            argv.push(format!("{name}: {value}"));
4953        }
4954        argv.push(endpoint.to_string());
4955        let out = proc::exec(
4956            &argv,
4957            &ExecOpts::new()
4958                .cwd(&self.root)
4959                .check(false)
4960                .timeout_secs(300),
4961        )?;
4962        parse_api_response(&out.stdout)
4963            .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
4964    }
4965
4966    /// Every open pull request, with what a sweep needs to know about each.
4967    pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
4968        let text = self.gh(&[
4969            "pr",
4970            "list",
4971            "--state",
4972            "open",
4973            "--limit",
4974            "500",
4975            "--json",
4976            PR_SWEEP_FIELDS,
4977        ])?;
4978        serde_json::from_str(&text)
4979            .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
4980    }
4981
4982    /// One pull request, open or not, with what a sweep needs to know.
4983    pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
4984        let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
4985        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4986    }
4987
4988    /// Whether `login` is in a team, named `org/slug` as gh gives a team in
4989    /// `reviewRequests`. GitHub answers 404 for a team the login is not in, and
4990    /// a team the viewer is in is one it can always see. None when GitHub did
4991    /// not answer either way.
4992    pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
4993        // A team asked for a review belongs to the repository's own owner.
4994        let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
4995        if slug.is_empty() || login.is_empty() {
4996            return None;
4997        }
4998        let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
4999        let resp = self.gh_api_raw(&path, &[]).ok()?;
5000        match resp.status {
5001            200 => {
5002                let body: Value = serde_json::from_str(&resp.body).ok()?;
5003                Some(body.get("state")?.as_str()? == "active")
5004            }
5005            404 => Some(false),
5006            _ => None,
5007        }
5008    }
5009
5010    /// Every review request made on a pull request, oldest first. None when
5011    /// GitHub did not answer.
5012    ///
5013    /// A review spar posts as a comment leaves the request it answered open,
5014    /// so `reviewRequests` alone cannot tell that request from one made after
5015    /// the review. When it was made can.
5016    pub fn review_requests_made(&self, number: i64) -> Option<Vec<crate::model::ReviewRequested>> {
5017        let text = self
5018            .gh(&[
5019                "api",
5020                "graphql",
5021                "-F",
5022                "owner={owner}",
5023                "-F",
5024                "repo={repo}",
5025                "-F",
5026                &format!("number={number}"),
5027                "-f",
5028                &format!("query={REQUESTS_QUERY}"),
5029            ])
5030            .ok()?;
5031        parse_review_requests(&text)
5032    }
5033
5034    /// The open issues assigned to `login`, with when each assignment was
5035    /// made. GitHub raises no notification for the viewer's own actions, so
5036    /// this is the only way `spar listen` learns that somebody assigned an
5037    /// issue to themselves.
5038    pub fn sweep_assigned_issues(&self, login: &str) -> Result<Vec<crate::model::AssignedIssue>> {
5039        let text = self.gh(&[
5040            "api",
5041            "graphql",
5042            "-F",
5043            "owner={owner}",
5044            "-F",
5045            "repo={repo}",
5046            "-f",
5047            &format!("login={login}"),
5048            "-f",
5049            &format!("query={}", assigned_issues_query()),
5050        ])?;
5051        parse_assigned_issues(&text)
5052            .ok_or_else(|| spar_err!("unexpected shape for the issues assigned to {login}"))
5053    }
5054
5055    /// Who issue `number` is assigned to now, and when each assignment was
5056    /// made.
5057    pub fn issue_assignments(&self, number: i64) -> Result<crate::model::AssignedIssue> {
5058        let text = self.gh(&[
5059            "api",
5060            "graphql",
5061            "-F",
5062            "owner={owner}",
5063            "-F",
5064            "repo={repo}",
5065            "-F",
5066            &format!("number={number}"),
5067            "-f",
5068            &format!("query={}", issue_assignments_query()),
5069        ])?;
5070        parse_issue_assignments(&text)
5071            .ok_or_else(|| spar_err!("unexpected shape for the assignments on #{number}"))
5072    }
5073
5074    /// How many commits lie between two SHAs, when GitHub will say.
5075    pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
5076        if from.is_empty() || to.is_empty() {
5077            return None;
5078        }
5079        let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
5080        self.gh_try(&["api", &path, "--jq", ".total_commits"])
5081            .trim()
5082            .parse()
5083            .ok()
5084    }
5085
5086    /// `owner/name`, for the line that says what is being watched.
5087    pub fn repo_name(&self) -> Option<String> {
5088        let name = self.gh_try(&[
5089            "repo",
5090            "view",
5091            "--json",
5092            "nameWithOwner",
5093            "--jq",
5094            ".nameWithOwner",
5095        ]);
5096        let name = name.trim();
5097        (!name.is_empty()).then(|| name.to_string())
5098    }
5099
5100    pub fn listen_state_path(&self) -> PathBuf {
5101        self.root.join(STATE_DIR).join("state").join("listen.json")
5102    }
5103
5104    pub fn listen_lock_path(&self) -> PathBuf {
5105        self.root.join(STATE_DIR).join("state").join("listen.lock")
5106    }
5107
5108    /// The login `gh` is authenticated as.
5109    ///
5110    /// A hard error, never a degradation. Everything spar wrote has to be
5111    /// excluded from what it answers, and custody cannot be read from git
5112    /// authorship, so this is the only thing that tells spar's own comments
5113    /// from somebody else's. Without it the failure is not "answers a bit too
5114    /// much", it is a thread where spar answers itself until somebody notices.
5115    ///
5116    /// Not cached on disk: `gh auth switch` between runs would make a stored
5117    /// answer wrong in exactly the way that produces that thread.
5118    pub fn viewer_login(&self) -> Result<&str> {
5119        if let Some(login) = self.viewer.get() {
5120            return Ok(login);
5121        }
5122        let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
5123        let login = if !rest.trim().is_empty() {
5124            rest.trim().to_string()
5125        } else {
5126            // A token that cannot read /user can still answer for itself in
5127            // GraphQL, which is the case on some Enterprise installs.
5128            self.gh(&[
5129                "api",
5130                "graphql",
5131                "-f",
5132                "query={ viewer { login } }",
5133                "--jq",
5134                ".data.viewer.login",
5135            ])
5136            .map_err(|e| {
5137                spar_err!(
5138                    "could not find out who `gh` is authenticated as, so spar cannot tell its \
5139                     own comments from anybody else's. {}\nRun `gh auth status`.",
5140                    e.last_line()
5141                )
5142            })?
5143            .trim()
5144            .to_string()
5145        };
5146        if login.is_empty() {
5147            bail!("`gh` reported an empty login. Run `gh auth status`.");
5148        }
5149        Ok(self.viewer.get_or_init(|| login))
5150    }
5151
5152    /// One issue as it stands, open or closed.
5153    ///
5154    /// `fetch_issues` reads a queue to work: it drops a closed issue and fails
5155    /// when nothing survives. Both are wrong for reading one issue back, where
5156    /// closed is an answer and the empty case cannot arise.
5157    pub fn read_issue(&self, number: i64) -> Result<Issue> {
5158        let text = self
5159            .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
5160            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
5161        serde_json::from_str(&text)
5162            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
5163    }
5164
5165    pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
5166        let mut issues = Vec::new();
5167        for number in numbers {
5168            let text = self
5169                .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
5170                .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
5171            let issue: Issue = serde_json::from_str(&text)
5172                .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
5173            if issue.is_closed() {
5174                crate::log!("issue #{number} is closed, skipping");
5175                continue;
5176            }
5177            issues.push(issue);
5178        }
5179        if issues.is_empty() {
5180            bail!("no open issues to work on");
5181        }
5182        Ok(issues)
5183    }
5184
5185    /// Open items, lowest numbered first, from `min_number` upward.
5186    ///
5187    /// The floor exists because a long lived repository accumulates a tail of
5188    /// old issues nobody is going to get to, and taking the lowest numbered
5189    /// open items means walking straight into them.
5190    fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
5191        #[derive(Deserialize)]
5192        struct Row {
5193            number: i64,
5194        }
5195        let text = self.gh(&[
5196            kind,
5197            "list",
5198            "--state",
5199            "open",
5200            "--limit",
5201            &FETCH_CEILING.to_string(),
5202            "--json",
5203            "number",
5204        ])?;
5205        let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
5206        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
5207        numbers.sort_unstable();
5208
5209        let noun = if kind == "issue" { "issues" } else { "PRs" };
5210        let found = numbers.len();
5211        if min_number > 0 {
5212            numbers.retain(|n| *n >= min_number);
5213            let skipped = found - numbers.len();
5214            if skipped > 0 {
5215                crate::log!("{skipped} open {noun} below #{min_number} skipped");
5216            }
5217        }
5218        if found >= FETCH_CEILING {
5219            crate::log!(
5220                "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
5221                 considered."
5222            );
5223        }
5224        if numbers.len() > limit {
5225            crate::log!(
5226                "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
5227                 explicitly.",
5228                numbers.len()
5229            );
5230            numbers.truncate(limit);
5231        }
5232        Ok(numbers)
5233    }
5234
5235    /// Open issues, lowest numbered first. `gh issue list` excludes PRs.
5236    pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
5237        self.open_numbers("issue", limit, min_number)
5238    }
5239
5240    pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
5241        self.open_numbers("pr", limit, min_number)
5242    }
5243
5244    pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
5245        self.branch_prs(branch, "open").into_iter().next()
5246    }
5247
5248    /// The open pull request for a branch, preserving a failed lookup as an
5249    /// error when the caller is deciding whether a write already landed.
5250    pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
5251        let text = self.gh(&[
5252            "pr",
5253            "list",
5254            "--head",
5255            branch,
5256            "--base",
5257            base,
5258            "--state",
5259            "open",
5260            "--json",
5261            "number,url,title,baseRefName",
5262        ])?;
5263        pr_for_base(&text, branch, base)
5264    }
5265
5266    /// Every pull request opened from this branch, merged and closed ones
5267    /// included, because a commit is preserved by whichever one carries it and
5268    /// that is rarely the newest.
5269    fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
5270        self.branch_prs(branch, "all")
5271    }
5272
5273    fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
5274        let text = self.gh_try(&[
5275            "pr",
5276            "list",
5277            "--head",
5278            branch,
5279            "--state",
5280            state,
5281            "--json",
5282            "number,url,title",
5283        ]);
5284        serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
5285    }
5286
5287    /// Whether a number names an issue or a pull request.
5288    ///
5289    /// `gh issue view` happily returns a pull request when handed its number,
5290    /// so it cannot be used to tell them apart. The issues API carries both and
5291    /// marks a pull request with a `pull_request` key, which is definitive.
5292    pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
5293        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
5294        let text = self
5295            .gh(&[
5296                "api",
5297                &path,
5298                "--jq",
5299                "if .pull_request then \"pr\" else \"issue\" end",
5300            ])
5301            .map_err(|e| {
5302                spar_err!(
5303                    "no issue or pull request #{number} in this repository. {}",
5304                    e.last_line()
5305                )
5306            })?;
5307        match text.trim() {
5308            "pr" => Ok(ItemKind::Pr),
5309            "issue" => Ok(ItemKind::Issue),
5310            other => Err(spar_err!(
5311                "could not tell whether #{number} is an issue or a pull request (got {other:?})"
5312            )),
5313        }
5314    }
5315
5316    /// An open pull request that would close this issue, whoever opened it.
5317    ///
5318    /// spar's own branch naming is checked first because it is exact and cheap.
5319    /// Falling back to GitHub's own issue linkage is what lets spar pick up a
5320    /// pull request a person started on a branch named anything at all.
5321    ///
5322    /// An error rather than `None` when the question could not be asked. The
5323    /// two answers are not interchangeable: "no pull request exists" is the one
5324    /// that makes spar implement over the top of somebody's work and open a
5325    /// second pull request for the same issue.
5326    pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
5327        let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
5328        if let Some(pr) = mine {
5329            return Ok(Some(pr));
5330        }
5331        let listed = match self.gh(&[
5332            "pr",
5333            "list",
5334            "--state",
5335            "open",
5336            "--limit",
5337            &FETCH_CEILING.to_string(),
5338            "--json",
5339            "number,url,title,baseRefName,closingIssuesReferences",
5340        ]) {
5341            Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
5342                spar_err!("could not read the pull request list while looking for #{issue}")
5343            })?,
5344            Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
5345        };
5346        if listed.len() >= FETCH_CEILING {
5347            logwarn!(
5348                "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a                  pull request past it cannot be seen from here."
5349            );
5350        }
5351        let default = self.default_branch(base);
5352        if !base.eq_ignore_ascii_case(&default) {
5353            // GitHub populates closingIssuesReferences only for pull requests
5354            // targeting the default branch, so on any other base the linkage is
5355            // silence rather than an answer.
5356            logwarn!(
5357                "#{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."
5358            );
5359        }
5360        Ok(pick_linked_pr(&listed, issue))
5361    }
5362
5363    /// spar's own branch, with a failed lookup kept as a failure.
5364    fn pr_for_branch_failing_closed(
5365        &self,
5366        branch: &str,
5367        base: &str,
5368        issue: i64,
5369    ) -> Result<Option<PrRef>> {
5370        match self.try_pr_for_branch(branch, base) {
5371            Ok(found) => Ok(found),
5372            Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
5373        }
5374    }
5375
5376    /// What to do when GitHub would not say whether a pull request exists.
5377    ///
5378    /// An error, because "no pull request exists" is the answer that makes spar
5379    /// implement from scratch and open a second pull request over somebody's
5380    /// work. The one exception is a repository gh cannot reach at all: there is
5381    /// no pull request to collide with when there is no GitHub repository in
5382    /// play, and every later write would fail anyway. That case is a warning and
5383    /// a `None`, so a run against a plain git remote behaves as it always did.
5384    fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
5385        if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
5386            return Err(spar_err!(
5387                "could not check whether a pull request already exists for #{issue}: {}. \
5388                 Refusing to implement over the top of work that may already be open.",
5389                e.last_line()
5390            ));
5391        }
5392        logwarn!(
5393            "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
5394             whether a pull request already exists for it.",
5395            e.last_line()
5396        );
5397        Ok(())
5398    }
5399
5400    pub fn pr_view(&self, number: i64) -> Result<PrView> {
5401        let text = self.gh(&[
5402            "pr",
5403            "view",
5404            &number.to_string(),
5405            "--json",
5406            "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,\
5407             isCrossRepository,headRepositoryOwner,headRepository",
5408        ])?;
5409        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
5410    }
5411
5412    fn try_pr_state(&self, number: i64) -> Result<String> {
5413        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
5414        serde_json::from_str::<Value>(text.trim())
5415            .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
5416            .get("state")
5417            .and_then(Value::as_str)
5418            .map(str::to_string)
5419            .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
5420    }
5421
5422    pub fn pr_state(&self, number: i64) -> String {
5423        self.try_pr_state(number).unwrap_or_default()
5424    }
5425
5426    /// The commit currently exposed as a pull request's head.
5427    pub fn pr_head_oid(&self, number: i64) -> Result<String> {
5428        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
5429        let oid = serde_json::from_str::<Value>(&text)
5430            .ok()
5431            .and_then(|value| {
5432                value
5433                    .get("headRefOid")
5434                    .and_then(Value::as_str)
5435                    .map(str::trim)
5436                    .filter(|oid| !oid.is_empty())
5437                    .map(str::to_string)
5438            })
5439            .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
5440        Ok(oid)
5441    }
5442
5443    /// Where a pull request's head stands now: what GitHub's pull request
5444    /// names, and, for a branch in this repository, where `origin` has the
5445    /// branch. The two differ until GitHub catches up with a push.
5446    pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
5447        let pull = self.pr_head_oid(pr.number)?;
5448        let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
5449            None
5450        } else {
5451            let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
5452            let listed = self
5453                .git(&["ls-remote", "--heads", "origin", &remote_ref])
5454                .map_err(|e| {
5455                    spar_err!(
5456                        "could not read where {} is on origin: {}",
5457                        pr.head_ref_name.trim(),
5458                        e.last_line()
5459                    )
5460                })?;
5461            remote_head_oid(&listed, &remote_ref)?
5462        };
5463        Ok(LiveHead { pull, branch })
5464    }
5465
5466    /// Open a pull request from `branch`, published at `target`.
5467    ///
5468    /// The lookup that confirms it landed goes by branch name alone, which
5469    /// `gh pr list --head` matches on a fork's pull request too.
5470    pub fn create_pr(
5471        &self,
5472        cwd: &Path,
5473        branch: &str,
5474        target: &PushTarget,
5475        base: &str,
5476        title: &str,
5477        body: &str,
5478    ) -> Result<PrRef> {
5479        let title = self.record_failed_write(self.clean_title(title))?;
5480        let body = self.record_failed_write(self.clean(body))?;
5481        let head = target.head(branch);
5482        let mut argv = vec![
5483            "pr", "create", "--base", base, "--head", &head, "--title", &title, "--body", &body,
5484        ];
5485        if self.drafts != Drafts::Never {
5486            argv.push("--draft");
5487        }
5488        let created = self.gh_at(Some(cwd), &argv);
5489        let found = self.try_pr_for_branch(branch, base);
5490        self.record_write(reconcile_pr_creation(branch, created, found))
5491    }
5492
5493    /// Comment on a pull request, and say where the comment is when GitHub
5494    /// did. An identical comment already there is this one.
5495    pub fn comment_pr(&self, number: i64, body: &str) -> Result<Option<String>> {
5496        let body = signed(&self.record_failed_write(self.clean(body))?);
5497        let comments = self.record_failed_write(self.try_issue_comments(number))?;
5498        if let Some(comment) = exact_comment(&comments, &body) {
5499            return Ok(html_url(comment));
5500        }
5501        let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
5502        let result = match posted {
5503            Ok(stdout) => Ok(printed_url(&stdout)),
5504            Err(post_error) => {
5505                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5506            }
5507        };
5508        self.record_write(result)
5509    }
5510
5511    /// Whether the viewer wrote this pull request. GitHub refuses to let an
5512    /// author approve or request changes on their own, so a verdict on one can
5513    /// only ever be a comment.
5514    fn pr_is_own(&self, number: i64) -> Result<bool> {
5515        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
5516        let author = serde_json::from_str::<Value>(&text)
5517            .ok()
5518            .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
5519            .unwrap_or_default();
5520        let viewer = self.viewer_login()?.to_string();
5521        Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
5522    }
5523
5524    /// Submit a formal review, or a comment carrying the verdict's words when
5525    /// GitHub will not take the verdict: the viewer's own pull request, or no
5526    /// verdict at all.
5527    ///
5528    /// Mirrors `comment_pr` step for step: the style gate, the signature, a
5529    /// duplicate check against the viewer's own reviews, the write, and a read
5530    /// back on failure so an unverifiable write is reported as one.
5531    ///
5532    /// `commit` is the commit the reviewers read. GitHub attaches a review
5533    /// that names no commit to whatever the head is when it arrives, so a push
5534    /// made while a review ran would otherwise receive an approval nobody
5535    /// gave it. `gh pr review` cannot name one, hence the REST call.
5536    ///
5537    /// `comments` go on their lines in the same review, so they arrive with
5538    /// it or not at all. `body` goes with them, and may leave to them what
5539    /// they say. `full` says everything, and is what goes up when they do not.
5540    /// GitHub refuses the whole review over one line its diff does not show,
5541    /// so when it refused this one and the read back finds nothing landed, the
5542    /// review goes up again without them, with `full`. Any other failure is
5543    /// reconciled as a review without them would be, never retried: a gateway
5544    /// error can arrive while GitHub is still saving a review that will land.
5545    ///
5546    /// Either body standing on `commit` already is this review, so a rerun
5547    /// after the fallback landed does not post it again.
5548    ///
5549    /// The address of whichever review stands is returned when GitHub gave
5550    /// one.
5551    pub fn submit_review(
5552        &self,
5553        number: i64,
5554        verdict: Option<crate::model::ReviewVerdict>,
5555        body: &str,
5556        full: &str,
5557        commit: &str,
5558        comments: &[InlineComment],
5559    ) -> Result<Option<String>> {
5560        let own = match verdict {
5561            Some(_) => self.record_failed_write(self.pr_is_own(number))?,
5562            None => false,
5563        };
5564        let prepare = |body: &str| {
5565            let text = match (verdict, own) {
5566                (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
5567                _ => body.to_string(),
5568            };
5569            self.record_failed_write(self.clean(&text))
5570                .map(|b| signed(&b))
5571        };
5572        let body = prepare(body)?;
5573        let full = prepare(full)?;
5574        let mut inline = Vec::with_capacity(comments.len());
5575        for c in comments {
5576            inline.push(InlineComment {
5577                body: signed(&self.record_failed_write(self.clean(&c.body))?),
5578                ..c.clone()
5579            });
5580        }
5581        let either = [body.as_str(), full.as_str()];
5582        let viewer = self.record_failed_write(self.viewer_login())?.to_string();
5583        let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
5584        if let Some(review) = viewer_review(&reviews, &viewer, &either, commit) {
5585            return Ok(html_url(review));
5586        }
5587        let event = match (verdict, own) {
5588            (Some(v), false) => v.api_event(),
5589            _ => "COMMENT",
5590        };
5591        let result = if inline.is_empty() {
5592            self.post_review(number, &full, &viewer, commit, event)
5593        } else {
5594            let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5595            let payload = review_payload(commit, event, &body, &inline).to_string();
5596            match self.gh_stdin(&["api", "-X", "POST", &path, "--input", "-"], &payload) {
5597                Ok(response) => Ok(review_url(&response)),
5598                Err(post_error) if !refused(&post_error) => reconcile_review_post(
5599                    number,
5600                    &[&body],
5601                    &viewer,
5602                    commit,
5603                    post_error,
5604                    self.try_pr_reviews(number),
5605                ),
5606                Err(post_error) => match self.try_pr_reviews(number) {
5607                    Ok(reviews) if !has_viewer_review(&reviews, &viewer, &either, commit) => {
5608                        logwarn!(
5609                            "the review of #{number} with its line comments did not go up ({}), \
5610                             so it is going up with every finding in its body only",
5611                            post_error.last_line()
5612                        );
5613                        self.post_review(number, &full, &viewer, commit, event)
5614                    }
5615                    reviews => {
5616                        reconcile_review_post(number, &either, &viewer, commit, post_error, reviews)
5617                    }
5618                },
5619            }
5620        };
5621        self.record_write(result)
5622    }
5623
5624    /// A review with its body alone, read back if the post reports a failure.
5625    fn post_review(
5626        &self,
5627        number: i64,
5628        body: &str,
5629        viewer: &str,
5630        commit: &str,
5631        event: &str,
5632    ) -> Result<Option<String>> {
5633        let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5634        let posted = self.gh(&[
5635            "api",
5636            "-X",
5637            "POST",
5638            &path,
5639            "-f",
5640            &format!("commit_id={commit}"),
5641            "-f",
5642            &format!("event={event}"),
5643            "-f",
5644            &format!("body={body}"),
5645        ]);
5646        match posted {
5647            Ok(response) => Ok(review_url(&response)),
5648            Err(post_error) => reconcile_review_post(
5649                number,
5650                &[body],
5651                viewer,
5652                commit,
5653                post_error,
5654                self.try_pr_reviews(number),
5655            ),
5656        }
5657    }
5658
5659    pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
5660        let body = signed(&self.record_failed_write(self.clean(body))?);
5661        let comments = self.record_failed_write(self.try_issue_comments(number))?;
5662        if exact_comment(&comments, &body).is_some() {
5663            return Ok(());
5664        }
5665        let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
5666        let result = match posted {
5667            Ok(_) => Ok(()),
5668            Err(post_error) => {
5669                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5670                    .map(|_| ())
5671            }
5672        };
5673        self.record_write(result)
5674    }
5675
5676    /// Comment, then close as not planned.
5677    ///
5678    /// Only ever called when both agents independently declined the issue: one
5679    /// agent's opinion is not enough to close somebody's report.
5680    pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
5681        self.comment_issue(number, body)?;
5682        let n = number.to_string();
5683        let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
5684            Ok(_) => Ok(()),
5685            // Older gh builds do not take --reason.
5686            Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
5687                spar_err!(
5688                    "commented on #{number} but could not close it: {}",
5689                    e.last_line()
5690                )
5691            }),
5692        };
5693        self.record_write(closed)
5694    }
5695
5696    /// Replace an issue body, refusing unless it is still byte for byte what
5697    /// the caller read and validating only the fragment spar inserted.
5698    ///
5699    /// The only place spar rewrites text somebody else wrote, so the check is
5700    /// the whole point: an edit computed from a body that has since moved would
5701    /// silently delete whatever moved it. The caller decides whether another
5702    /// attempt is safe for its workflow.
5703    ///
5704    /// Deliberately not through `clean_issue_body`. The body is mostly a
5705    /// person's own prose, and the scrub would rewrite their punctuation while
5706    /// the length budget could truncate the end of a long report. `inserted` is
5707    /// the only text here spar is answerable for, so it still passes through the
5708    /// style gate. The full body travels over stdin because a tracker can be far
5709    /// too long for one argument.
5710    pub fn edit_issue_body(
5711        &self,
5712        number: i64,
5713        expected: &str,
5714        body: &str,
5715        inserted: &str,
5716    ) -> Result<()> {
5717        let cleaned = self.record_failed_write(self.clean(inserted))?;
5718        if cleaned.trim() != inserted.trim() {
5719            return self.record_failed_write(Err(spar_err!(
5720                "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
5721            )));
5722        }
5723        let current = self.record_failed_write(self.issue_body(number))?;
5724        if current != expected {
5725            return self.record_failed_write(Err(spar_err!(
5726                "the body of #{number} changed since it was read, so it was left alone rather \
5727                 than written over."
5728            )));
5729        }
5730        let edited = self.gh_stdin(
5731            &["issue", "edit", &number.to_string(), "--body-file", "-"],
5732            body,
5733        );
5734        let result = match edited {
5735            Ok(_) => Ok(()),
5736            Err(edit_error) => {
5737                reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
5738            }
5739        };
5740        self.record_write(result)
5741    }
5742
5743    /// One issue's body, exactly as GitHub holds it.
5744    pub fn issue_body(&self, number: i64) -> Result<String> {
5745        #[derive(Deserialize)]
5746        struct Row {
5747            #[serde(default)]
5748            body: Option<String>,
5749        }
5750        let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
5751        let row: Row = serde_json::from_str(text.trim())
5752            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
5753        Ok(row.body.unwrap_or_default())
5754    }
5755
5756    /// Every open issue with its title and body, in one call.
5757    ///
5758    /// For a screen that has to say something about each of twenty items before
5759    /// anything expensive happens. One call rather than one per issue.
5760    pub fn open_issue_rows(&self) -> Vec<Issue> {
5761        let text = self.gh_try(&[
5762            "issue",
5763            "list",
5764            "--state",
5765            "open",
5766            "--limit",
5767            &FETCH_CEILING.to_string(),
5768            "--json",
5769            "number,title,body,labels,state,stateReason,url",
5770        ]);
5771        serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
5772    }
5773
5774    /// Every open pull request with its size, in one call.
5775    pub fn open_pr_rows(&self) -> Vec<PrRow> {
5776        let text = self.gh_try(&[
5777            "pr",
5778            "list",
5779            "--state",
5780            "open",
5781            "--limit",
5782            &FETCH_CEILING.to_string(),
5783            "--json",
5784            "number,title,changedFiles,additions,deletions",
5785        ]);
5786        serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
5787    }
5788
5789    pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
5790        self.create_issue_apart_from(title, body, None)
5791    }
5792
5793    pub fn create_issue_apart_from(
5794        &self,
5795        title: &str,
5796        body: &str,
5797        apart_from: Option<i64>,
5798    ) -> Result<String> {
5799        let title = self.record_failed_write(self.clean_title(title))?;
5800        let body = self.record_failed_write(self.clean_issue_body(body))?;
5801        let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
5802        let result = match created {
5803            Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
5804            created => {
5805                let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
5806                reconcile_issue_creation(&title, created, found)
5807            }
5808        };
5809        self.record_write(result)
5810    }
5811}
5812
5813/// An issue that already covers what spar was about to file.
5814#[derive(Debug, Clone)]
5815pub struct ExistingIssue {
5816    pub number: i64,
5817    pub url: String,
5818    pub title: String,
5819    pub body: String,
5820    pub open: bool,
5821}
5822
5823impl Repo {
5824    pub(crate) fn try_exact_issue_apart_from(
5825        &self,
5826        title: &str,
5827        body: &str,
5828        apart_from: Option<i64>,
5829    ) -> Result<Option<ExistingIssue>> {
5830        #[derive(Deserialize)]
5831        #[serde(rename_all = "camelCase")]
5832        struct Row {
5833            number: i64,
5834            #[serde(default)]
5835            title: String,
5836            #[serde(default)]
5837            url: String,
5838            #[serde(default)]
5839            body: Option<String>,
5840            #[serde(default)]
5841            state: String,
5842        }
5843
5844        let text = self.gh(&[
5845            "issue",
5846            "list",
5847            "--state",
5848            "all",
5849            "--limit",
5850            "100",
5851            "--json",
5852            "number,title,url,body,state,stateReason",
5853        ])?;
5854        let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5855            .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
5856        Ok(rows
5857            .into_iter()
5858            .filter(|row| Some(row.number) != apart_from)
5859            .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
5860            .map(|row| ExistingIssue {
5861                number: row.number,
5862                url: row.url,
5863                title: row.title,
5864                body: row.body.unwrap_or_default(),
5865                open: row.state.eq_ignore_ascii_case("open"),
5866            }))
5867    }
5868
5869    /// An issue that already describes this defect, however it was worded.
5870    ///
5871    /// Exact title matching let duplicates through: two agents, or two runs a
5872    /// week apart, never word one defect identically. A real run filed two
5873    /// duplicates that way, and each had to be closed by hand afterwards.
5874    /// Titles alone are too thin to match on, so this compares titles and
5875    /// bodies together.
5876    pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
5877        self.find_similar_issue_apart_from(title, body, None)
5878    }
5879
5880    /// The same search, with one issue that cannot be its own duplicate.
5881    ///
5882    /// A tracker's body quotes every item in its checklist, so searching for an
5883    /// item's words matches the tracker before it matches anything else. That
5884    /// would link an item to the issue it is written in.
5885    /// The search a tracker runs, which wants closed issues too: a checklist
5886    /// item matching a closed issue is a done item that wants a link.
5887    pub fn find_similar_issue_apart_from(
5888        &self,
5889        title: &str,
5890        body: &str,
5891        apart_from: Option<i64>,
5892    ) -> Option<ExistingIssue> {
5893        self.try_similar_issue(title, body, apart_from, true)
5894            .ok()
5895            .flatten()
5896    }
5897
5898    /// The search a filing runs, over open issues only.
5899    pub fn try_find_similar_issue_apart_from(
5900        &self,
5901        title: &str,
5902        body: &str,
5903        apart_from: Option<i64>,
5904    ) -> Result<Option<ExistingIssue>> {
5905        self.try_similar_issue(title, body, apart_from, false)
5906    }
5907
5908    /// The same search, preserving lookup failure for a caller about to write.
5909    fn try_similar_issue(
5910        &self,
5911        title: &str,
5912        body: &str,
5913        apart_from: Option<i64>,
5914        include_closed: bool,
5915    ) -> Result<Option<ExistingIssue>> {
5916        #[derive(Deserialize)]
5917        #[serde(rename_all = "camelCase")]
5918        struct Row {
5919            number: i64,
5920            #[serde(default)]
5921            title: String,
5922            #[serde(default)]
5923            url: String,
5924            #[serde(default)]
5925            body: String,
5926            #[serde(default)]
5927            state: String,
5928        }
5929        if title.trim().is_empty() {
5930            return Ok(None);
5931        }
5932        // Search on the title's own words: GitHub's index is the cheap way to
5933        // narrow the field before comparing properly.
5934        let query: String = title
5935            .chars()
5936            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5937            .take(120)
5938            .collect();
5939        // Open issues only when something is about to be filed. A fuzzy match
5940        // against a closed one is the case worth keeping rather than dropping:
5941        // a defect that was fixed, closed, and has come back is exactly the
5942        // finding a maintainer wants, and "#N covers it and is closed" wrote it
5943        // nowhere. An exact match is still checked against every state, since
5944        // that is a regression of one specific issue rather than a resemblance
5945        // to one, and a tracker still searches closed issues because a
5946        // checklist item matching one is a done item that wants a link.
5947        let state = if include_closed { "all" } else { "open" };
5948        let text = self.gh(&[
5949            "issue",
5950            "list",
5951            "--state",
5952            state,
5953            "--limit",
5954            "100",
5955            "--search",
5956            query.trim(),
5957            "--json",
5958            "number,title,url,body,state,stateReason",
5959        ])?;
5960        let rows: Vec<Row> = serde_json::from_str(text.trim())
5961            .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
5962
5963        Ok(rows
5964            .into_iter()
5965            .filter(|row| Some(row.number) != apart_from)
5966            .find(|row| {
5967                row.title.trim().eq_ignore_ascii_case(title.trim())
5968                    || textsim::covers(title, body, &row.title, &row.body)
5969            })
5970            .map(|row| ExistingIssue {
5971                number: row.number,
5972                url: row.url,
5973                title: row.title,
5974                open: row.state.eq_ignore_ascii_case("open"),
5975                body: row.body,
5976            }))
5977    }
5978
5979    /// Avoid filing a duplicate when a follow-up already exists.
5980    pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
5981        #[derive(Deserialize)]
5982        struct Row {
5983            title: String,
5984            url: String,
5985        }
5986        let needle = title.trim().to_lowercase();
5987        if needle.is_empty() {
5988            return None;
5989        }
5990        // Quotes and newlines would be read as search syntax rather than text.
5991        let query: String = title
5992            .chars()
5993            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5994            .take(120)
5995            .collect();
5996        let text = self.gh_try(&[
5997            "issue",
5998            "list",
5999            "--state",
6000            "all",
6001            "--limit",
6002            "100",
6003            "--search",
6004            query.trim(),
6005            "--json",
6006            "number,title,url",
6007        ]);
6008        serde_json::from_str::<Vec<Row>>(text.trim())
6009            .ok()?
6010            .into_iter()
6011            .find(|row| row.title.trim().to_lowercase() == needle)
6012            .map(|row| row.url)
6013    }
6014
6015    /// Squash merge, tolerating cleanup failures after a successful merge.
6016    ///
6017    /// Take a pull request out of draft, once the review has converged.
6018    ///
6019    /// Best effort for the remaining workflow. A failure does not discard the
6020    /// review or stop later independent work, but the final write summary
6021    /// reports it and the command returns non-zero.
6022    pub fn mark_ready(&self, number: i64) -> bool {
6023        match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
6024            Ok(_) => true,
6025            Err(e) => {
6026                logdim!(
6027                    "PR #{number} is approved but could not be taken out of draft: {}",
6028                    e.last_line()
6029                );
6030                false
6031            }
6032        }
6033    }
6034
6035    /// The worktree that has `branch` checked out, if one does.
6036    fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
6037        let listed = self
6038            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
6039            .ok()?;
6040        let wanted = format!("branch refs/heads/{branch}");
6041        let mut current: Option<PathBuf> = None;
6042        for line in listed.lines() {
6043            if let Some(path) = line.strip_prefix("worktree ") {
6044                current = Some(PathBuf::from(path));
6045            } else if line == wanted {
6046                return current;
6047            }
6048        }
6049        None
6050    }
6051
6052    /// Finish the local cleanup a merge could not.
6053    ///
6054    /// `gh pr merge --delete-branch` gives up on the local branch whenever any
6055    /// worktree has it checked out, which is the ordinary state rather than an
6056    /// odd one: `spar run` opens the pull request from
6057    /// `.spar-worktrees/issue-N` and leaves the checkout there, and the merge
6058    /// usually happens in a later `spar resume` working somewhere else. The
6059    /// next run's sweep tidied it, so nothing was ever lost, but the run that
6060    /// did the merge reported a failure about a branch nobody will touch again
6061    /// and left a checkout for the next run to explain.
6062    ///
6063    /// Held to the same rules the sweep uses, because they are the rules that
6064    /// decide whether anything would be lost: a branch spar recorded creating,
6065    /// a worktree of spar's own with no recoverable work in it, and a tip some
6066    /// surviving ref preserves. Anything else is left exactly where it is.
6067    fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
6068        let branch = self.pr_view(number).ok()?.head_ref_name;
6069        if branch.is_empty() || !self.known_branches().contains_key(&branch) {
6070            return None;
6071        }
6072        let mut released = None;
6073        if let Some(path) = self.worktree_holding(&branch) {
6074            if !path.starts_with(self.root.join(WORKTREE_DIR)) {
6075                return None;
6076            }
6077            if !matches!(
6078                self.has_recoverable_work(&path, Disposable::BuildOutput),
6079                Ok(false)
6080            ) {
6081                return None;
6082            }
6083            match self.remove_worktree_at(&path) {
6084                Ok(true) => released = Some(path),
6085                _ => return None,
6086            }
6087        }
6088        match self.delete_branch_if_safe(&branch) {
6089            Ok(true) => {
6090                self.forget_branch(&branch);
6091                Some(match released {
6092                    Some(path) => format!(
6093                        " The worktree at {} was released and branch {branch} deleted.",
6094                        path.display()
6095                    ),
6096                    None => format!(" Branch {branch} was deleted."),
6097                })
6098            }
6099            _ => released.map(|path| {
6100                format!(
6101                    " The worktree at {} was released, but branch {branch} was kept.",
6102                    path.display()
6103                )
6104            }),
6105        }
6106    }
6107
6108    /// `gh pr merge --delete-branch` exits non-zero when it cannot delete the
6109    /// local branch, which happens *after* the merge has already landed.
6110    /// Treating that as a failure reports work as lost when it is not.
6111    pub fn merge_pr(&self, number: i64) -> Result<()> {
6112        let n = number.to_string();
6113        let merged = match self.gh(&merge_pr_args(&n, None, true)) {
6114            Ok(_) => Ok(()),
6115            Err(e) => {
6116                if self.pr_state(number) == "MERGED" {
6117                    let finished = self
6118                        .finish_merged_branch_cleanup(number)
6119                        .unwrap_or_default();
6120                    logdim!(
6121                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
6122                        e.last_line()
6123                    );
6124                    Ok(())
6125                } else {
6126                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
6127                }
6128            }
6129        };
6130        self.record_write(merged)
6131    }
6132
6133    /// Squash merge only if the pull request still exposes the reviewed head.
6134    pub fn merge_pr_at_head(
6135        &self,
6136        number: i64,
6137        expected_head: &str,
6138        delete_branch: bool,
6139    ) -> Result<()> {
6140        let n = number.to_string();
6141        let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
6142            Ok(_) => Ok(()),
6143            Err(e) => {
6144                if self.pr_state(number) == "MERGED" {
6145                    let finished = self
6146                        .finish_merged_branch_cleanup(number)
6147                        .unwrap_or_default();
6148                    logdim!(
6149                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
6150                        e.last_line()
6151                    );
6152                    Ok(())
6153                } else {
6154                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
6155                }
6156            }
6157        };
6158        self.record_write(merged)
6159    }
6160
6161    // -- follow-ups -------------------------------------------------------
6162
6163    /// The queue of follow-ups recorded locally rather than filed, which
6164    /// `spar followup` works.
6165    pub fn followups_path(&self) -> PathBuf {
6166        self.root.join(STATE_DIR).join("followups.md")
6167    }
6168
6169    /// Where `spar brainstorm` writes a session unless told otherwise.
6170    ///
6171    /// Under the state directory, so it is kept out of the repository's own
6172    /// `git status`; `--out` puts one somewhere a person would commit.
6173    pub fn brainstorms_dir(&self) -> PathBuf {
6174        self.root.join(STATE_DIR).join("brainstorms")
6175    }
6176
6177    /// What `spar followup` already dealt with, kept beside the queue.
6178    ///
6179    /// Two jobs. It is what stops `append_local_followup` re-recording a
6180    /// follow-up whose entry has since left the queue, which would otherwise
6181    /// turn the file into a ring buffer of things already filed. And it keeps
6182    /// the text of an entry a screening pass ruled stale, so a wrong verdict
6183    /// costs a re-read rather than the only copy of a real defect.
6184    pub fn worked_followups_path(&self) -> PathBuf {
6185        self.root.join(STATE_DIR).join("followups.done.md")
6186    }
6187
6188    /// What `spar checkin` has already answered on one pull request or issue.
6189    /// Where what a run spent is kept.
6190    pub fn spend_path(&self) -> PathBuf {
6191        self.root.join(STATE_DIR).join("state").join("spend.json")
6192    }
6193
6194    /// Append this run's calls to the record.
6195    ///
6196    /// Appended rather than replaced, so a resumed pull request adds to the
6197    /// count of what it has cost so far rather than starting it again, and so
6198    /// anybody charting this has a file to chart.
6199    pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
6200        if calls.is_empty() {
6201            return Ok(());
6202        }
6203        let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
6204            .ok()
6205            .and_then(|text| serde_json::from_str(&text).ok())
6206            .unwrap_or_default();
6207        all.extend(calls.iter().cloned());
6208        if let Some(parent) = self.spend_path().parent() {
6209            let _ = std::fs::create_dir_all(parent);
6210        }
6211        write_json_atomic(&self.spend_path(), &all)
6212    }
6213
6214    /// Who wrote the first draft of each issue, by number.
6215    ///
6216    /// Custody alternates within a pull request and the record of it lives in
6217    /// that pull request's state. This is the other question, which nothing
6218    /// answered: over a queue, which model authored what.
6219    pub fn implementors_path(&self) -> PathBuf {
6220        self.root
6221            .join(STATE_DIR)
6222            .join("state")
6223            .join("implementors.json")
6224    }
6225
6226    pub fn read_implementors(&self) -> BTreeMap<i64, String> {
6227        std::fs::read_to_string(self.implementors_path())
6228            .ok()
6229            .and_then(|text| serde_json::from_str(&text).ok())
6230            .unwrap_or_default()
6231    }
6232
6233    /// Best effort: this is a record for a person, not something the loop reads
6234    /// back, so a failure to write it is not a reason to stop.
6235    pub fn remember_implementor(&self, issue: i64, agent: &str) {
6236        let mut known = self.read_implementors();
6237        known.insert(issue, agent.to_string());
6238        if let Some(parent) = self.implementors_path().parent() {
6239            let _ = std::fs::create_dir_all(parent);
6240        }
6241        if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
6242            logdim!("could not record who implemented #{issue}: {e}");
6243        }
6244    }
6245
6246    /// Where the issues triage disagreed about are remembered.
6247    ///
6248    /// One file rather than one per issue: it is read once per run, before any
6249    /// triage call, to decide what not to ask about again.
6250    pub fn contested_path(&self) -> PathBuf {
6251        self.root
6252            .join(STATE_DIR)
6253            .join("state")
6254            .join("contested.json")
6255    }
6256
6257    /// The issues a person has been asked to decide, by number.
6258    pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
6259        std::fs::read_to_string(self.contested_path())
6260            .ok()
6261            .and_then(|text| serde_json::from_str(&text).ok())
6262            .unwrap_or_default()
6263    }
6264
6265    /// Remember an issue triage could not agree on.
6266    ///
6267    /// Best effort: a run that cannot write this asks again next time, which is
6268    /// what it did before this existed.
6269    pub fn remember_contested(&self, items: &[ContestedItem]) {
6270        if items.is_empty() {
6271            return;
6272        }
6273        let mut known = self.read_contested();
6274        for item in items {
6275            known.insert(
6276                item.issue,
6277                Contested {
6278                    title: item.title.clone(),
6279                    positions: item.positions.clone(),
6280                    reasons: item.reasons.clone(),
6281                },
6282            );
6283        }
6284        if let Some(parent) = self.contested_path().parent() {
6285            let _ = std::fs::create_dir_all(parent);
6286        }
6287        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
6288            logdim!("could not record the contested issues: {e}");
6289        }
6290    }
6291
6292    /// Ask about them again, because a person has acted on them.
6293    pub fn forget_contested(&self, numbers: &[i64]) {
6294        let mut known = self.read_contested();
6295        let before = known.len();
6296        known.retain(|number, _| !numbers.contains(number));
6297        if known.len() == before {
6298            return;
6299        }
6300        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
6301            logdim!("could not update the contested issues: {e}");
6302        }
6303    }
6304
6305    pub fn checkin_state_path(&self, number: i64) -> PathBuf {
6306        self.root
6307            .join(STATE_DIR)
6308            .join("state")
6309            .join(format!("checkin-{number}.json"))
6310    }
6311
6312    /// Where `spar answer` records which mentions on an issue it judged.
6313    pub fn answer_state_path(&self, number: i64) -> PathBuf {
6314        self.root
6315            .join(STATE_DIR)
6316            .join("state")
6317            .join(format!("answer-{number}.json"))
6318    }
6319
6320    /// Where `spar answer` keeps what it left for a person to answer. Notes,
6321    /// never posted.
6322    pub fn answer_notes_path(&self, number: i64) -> PathBuf {
6323        self.root
6324            .join(STATE_DIR)
6325            .join("answers")
6326            .join(format!("issue-{number}.md"))
6327    }
6328
6329    /// Where `spar review` records what it reviewed, and a second look reads it.
6330    pub fn review_record_path(&self, number: i64) -> PathBuf {
6331        self.root
6332            .join(STATE_DIR)
6333            .join("state")
6334            .join(format!("review-{number}.json"))
6335    }
6336
6337    /// None when there is no record, or when it cannot be read. A record spar
6338    /// cannot read is one it reviews afresh over, and the log says so.
6339    pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
6340        let path = self.review_record_path(number);
6341        let text = std::fs::read_to_string(&path).ok()?;
6342        match serde_json::from_str(&text) {
6343            Ok(record) => Some(record),
6344            Err(e) => {
6345                logdim!("could not read {}: {e}", path.display());
6346                None
6347            }
6348        }
6349    }
6350
6351    pub fn write_review_record(
6352        &self,
6353        number: i64,
6354        record: &crate::model::ReviewRecord,
6355    ) -> Result<()> {
6356        write_json_atomic(&self.review_record_path(number), record)
6357    }
6358
6359    /// Append a follow-up to a local note instead of the tracker.
6360    ///
6361    /// Deduplicated on the title, matching the issue path. The body arrives
6362    /// with its provenance already stamped by the caller, so nothing is added
6363    /// here.
6364    ///
6365    /// A write that did not happen is reported as such rather than as a
6366    /// duplicate: the caller settles the point on the strength of this answer,
6367    /// and settling it on a failed write is how a real defect is lost.
6368    ///
6369    /// Both files are checked, because `spar followup` removes an entry from
6370    /// the queue once it has filed it. Checking only the queue would let the
6371    /// next run that rediscovers the same defect append it again, on top of the
6372    /// issue that now exists for it.
6373    pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
6374        let path = self.followups_path();
6375        let heading = format!("## {}", title.trim());
6376        for seen in [&path, &self.worked_followups_path()] {
6377            if let Ok(existing) = std::fs::read_to_string(seen) {
6378                if has_heading(&existing, title) {
6379                    logdim!("follow-up already noted: {title}");
6380                    return Followup::Covered(format!("note: {}", title.trim()));
6381                }
6382            }
6383        }
6384        if let Some(parent) = path.parent() {
6385            let _ = std::fs::create_dir_all(parent);
6386        }
6387        use std::io::Write;
6388        // The caller already stamped the provenance into the body. Adding
6389        // "From #N." here as well printed it twice, in two different wordings.
6390        //
6391        // The marker above the heading is what makes the entry boundary
6392        // unambiguous to the parser, since the body's own sections are written
6393        // at the same heading level as the title.
6394        let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
6395        match std::fs::OpenOptions::new()
6396            .create(true)
6397            .append(true)
6398            .open(&path)
6399        {
6400            Ok(mut file) => match file.write_all(entry.as_bytes()) {
6401                Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
6402                Err(e) => {
6403                    logdim!("could not write {}: {e}", path.display());
6404                    Followup::Failed
6405                }
6406            },
6407            Err(e) => {
6408                logdim!("could not write {}: {e}", path.display());
6409                Followup::Failed
6410            }
6411        }
6412    }
6413
6414    /// Record what `spar followup` did with an entry, and why.
6415    ///
6416    /// Best effort: an archive that could not be written is not a reason to
6417    /// stop, since the entry has already been filed or ruled on.
6418    pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
6419        let path = self.worked_followups_path();
6420        if let Some(parent) = path.parent() {
6421            let _ = std::fs::create_dir_all(parent);
6422        }
6423        use std::io::Write;
6424        let entry = format!(
6425            "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
6426            title.trim(),
6427            body.trim()
6428        );
6429        if let Ok(mut file) = std::fs::OpenOptions::new()
6430            .create(true)
6431            .append(true)
6432            .open(&path)
6433        {
6434            let _ = file.write_all(entry.as_bytes());
6435        }
6436    }
6437
6438    // -- resumable state --------------------------------------------------
6439    //
6440    // Custody cannot be read from GitHub authorship: every agent commits and
6441    // comments as the same git identity, so `author` is always the human who
6442    // ran spar. State is kept on disk by default and can additionally travel in
6443    // a PR comment, which is what lets a run be resumed from another machine.
6444
6445    /// Where a comment spar produced but did not post is kept.
6446    pub fn pending_comment_path(&self, number: i64) -> PathBuf {
6447        self.root
6448            .join(STATE_DIR)
6449            .join("reviews")
6450            .join(format!("pr-{number}.md"))
6451    }
6452
6453    /// Keep a comment spar decided not to post.
6454    ///
6455    /// A dry run that prints and forgets means agreeing with what you read
6456    /// costs a second full review. Saving it makes the whole point of reading
6457    /// it first: look, edit if you like, then post what you already paid for.
6458    pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
6459        let path = self.pending_comment_path(number);
6460        if let Some(parent) = path.parent() {
6461            std::fs::create_dir_all(parent)
6462                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6463        }
6464        std::fs::write(&path, text)
6465            .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6466        Ok(path)
6467    }
6468
6469    pub fn read_pending_comment(&self, number: i64) -> Option<String> {
6470        std::fs::read_to_string(self.pending_comment_path(number)).ok()
6471    }
6472
6473    pub fn state_path(&self, number: i64) -> PathBuf {
6474        self.root
6475            .join(STATE_DIR)
6476            .join("state")
6477            .join(format!("pr-{number}.json"))
6478    }
6479
6480    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
6481        let path = self.state_path(number);
6482        let text = std::fs::read_to_string(&path).ok()?;
6483        match serde_json::from_str(&text) {
6484            Ok(state) => Some(state),
6485            Err(_) => {
6486                logdim!("could not read {}, starting fresh", path.display());
6487                None
6488            }
6489        }
6490    }
6491
6492    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
6493        if let Some(local) = self.read_local_state(pr.number) {
6494            return Some(local);
6495        }
6496        if self.state_store.writes_pr() {
6497            return self.read_pr_state(pr.number);
6498        }
6499        None
6500    }
6501
6502    pub(crate) fn read_state_for_head(
6503        &self,
6504        pr: &PrView,
6505        actual_head: &str,
6506    ) -> Option<PersistedState> {
6507        let local = self
6508            .state_store
6509            .writes_local()
6510            .then(|| self.read_local_state(pr.number))
6511            .flatten();
6512        let remote = self
6513            .state_store
6514            .writes_pr()
6515            .then(|| self.read_pr_state(pr.number))
6516            .flatten();
6517        let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
6518        if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
6519            self.remember_checkpoint(pr.number, checkpoint);
6520        }
6521        choose_state_for_head(candidates, actual_head)
6522    }
6523
6524    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
6525        self.try_read_pr_state(number).ok().flatten()
6526    }
6527
6528    fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
6529        for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
6530            if let Some(state) = parse_state_comment(&body) {
6531                return Ok(Some(state));
6532            }
6533        }
6534        Ok(None)
6535    }
6536
6537    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6538        let remote_state = if self.state_store.writes_pr() {
6539            self.try_read_pr_state(number)
6540        } else {
6541            Ok(None)
6542        };
6543        self.write_state_after_remote_read(number, state, remote_state)
6544    }
6545
6546    fn write_state_after_remote_read(
6547        &self,
6548        number: i64,
6549        state: &PersistedState,
6550        remote_state: Result<Option<PersistedState>>,
6551    ) -> Result<()> {
6552        let remote_checkpoint = if self.state_store.writes_pr() {
6553            self.record_failed_write(remote_state)?
6554                .map(|saved| saved.checkpoint)
6555                .unwrap_or_default()
6556        } else {
6557            0
6558        };
6559        let local_checkpoint = self
6560            .state_store
6561            .writes_local()
6562            .then(|| self.read_local_state(number))
6563            .flatten()
6564            .map(|saved| saved.checkpoint)
6565            .unwrap_or_default();
6566        let mut stamped = state.clone();
6567        stamped.checkpoint = state
6568            .checkpoint
6569            .max(local_checkpoint)
6570            .max(remote_checkpoint)
6571            .max(self.remembered_checkpoint(number))
6572            .saturating_add(1);
6573        self.remember_checkpoint(number, stamped.checkpoint);
6574        if self.state_store.writes_local() {
6575            write_json_atomic(&self.state_path(number), &stamped)?;
6576        }
6577        if self.state_store.writes_pr() {
6578            self.write_pr_state(number, &stamped)?;
6579        }
6580        Ok(())
6581    }
6582
6583    fn remembered_checkpoint(&self, number: i64) -> u64 {
6584        self.checkpoints
6585            .lock()
6586            .unwrap_or_else(std::sync::PoisonError::into_inner)
6587            .get(&number)
6588            .copied()
6589            .unwrap_or_default()
6590    }
6591
6592    fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
6593        let mut checkpoints = self
6594            .checkpoints
6595            .lock()
6596            .unwrap_or_else(std::sync::PoisonError::into_inner);
6597        let saved = checkpoints.entry(number).or_default();
6598        *saved = (*saved).max(checkpoint);
6599    }
6600
6601    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6602        // Not run through clean(): this is structured data, and scrubbing would
6603        // corrupt refutation text stored in the ledger. It sits inside an
6604        // unclosed HTML comment so GitHub renders it as nothing.
6605        let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
6606        let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
6607        let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
6608        if let Some(id) = comment_id {
6609            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6610            let field = format!("body={body}");
6611            let written = self
6612                .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
6613                .map(|_| ());
6614            return self.record_write(written);
6615        }
6616        let written = self
6617            .gh(&["pr", "comment", &number.to_string(), "--body", &body])
6618            .map(|_| ());
6619        self.record_write(written)
6620    }
6621
6622    /// Top level comments. Works for issues and pull requests alike, because
6623    /// GitHub serves both from the issues endpoint.
6624    ///
6625    /// Nothing when they cannot be read, which suits a reader that is going to
6626    /// go on regardless. A caller deciding whether it has already written here
6627    /// wants `try_issue_comments`, since for that one no comments and no answer
6628    /// are opposite answers.
6629    pub fn issue_comments(&self, number: i64) -> Vec<Value> {
6630        self.try_issue_comments(number).unwrap_or_default()
6631    }
6632
6633    pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
6634        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
6635        try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
6636    }
6637
6638    /// An issue as the REST API returns it, which is the one read that says
6639    /// who filed it in the shape a comment has: `user` with its `type`, and
6640    /// `author_association`, neither of which `gh issue view` gives.
6641    pub fn issue_row(&self, number: i64) -> Result<Value> {
6642        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
6643        let text = self
6644            .gh(&["api", &path])
6645            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
6646        serde_json::from_str(&text)
6647            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
6648    }
6649
6650    /// Open issues in this repository that mention `login`, lowest numbered
6651    /// first.
6652    pub fn open_issues_mentioning(&self, login: &str, limit: usize) -> Result<Vec<i64>> {
6653        #[derive(Deserialize)]
6654        struct Row {
6655            number: i64,
6656        }
6657        // `{owner}/{repo}` is filled in by `gh api` alone.
6658        let name = self
6659            .repo_name()
6660            .ok_or_else(|| spar_err!("could not find out which repository this is"))?;
6661        let text = self.gh(&[
6662            "search",
6663            "issues",
6664            "--repo",
6665            &name,
6666            "--mentions",
6667            login,
6668            "--state",
6669            "open",
6670            "--limit",
6671            &FETCH_CEILING.to_string(),
6672            "--json",
6673            "number",
6674        ])?;
6675        let rows: Vec<Row> = serde_json::from_str(text.trim())
6676            .map_err(|e| spar_err!("unexpected shape for the issues mentioning @{login}: {e}"))?;
6677        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
6678        numbers.sort_unstable();
6679        numbers.truncate(limit);
6680        Ok(numbers)
6681    }
6682
6683    /// spar's own state comments on a pull request, newest last.
6684    ///
6685    /// Three tests, because the answer decides what gets patched over and what
6686    /// gets deleted. A comment matching the marker loosely, which is what
6687    /// somebody asking "why is there a `<!-- spar:state` block on this PR?"
6688    /// writes, is named in the log and left exactly where it is.
6689    fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
6690        let viewer = self.viewer_login()?.to_string();
6691        let mut mine = Vec::new();
6692        let mut theirs = Vec::new();
6693        for comment in self.try_issue_comments(number)? {
6694            let Some(body) = comment.get("body").and_then(Value::as_str) else {
6695                continue;
6696            };
6697            if !body.contains("spar:state") {
6698                continue;
6699            }
6700            let Some(id) = comment.get("id").and_then(Value::as_i64) else {
6701                continue;
6702            };
6703            let author = comment
6704                .get("user")
6705                .and_then(|u| u.get("login"))
6706                .and_then(Value::as_str)
6707                .unwrap_or_default();
6708            if is_state_comment(body, author, &viewer) {
6709                mine.push((id, body.to_string()));
6710            } else {
6711                theirs.push(format!("@{author}"));
6712            }
6713        }
6714        if !theirs.is_empty() {
6715            logdim!(
6716                "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
6717                 own state block is patched or deleted.",
6718                theirs.len(),
6719                theirs.join(", ")
6720            );
6721        }
6722        Ok(mine)
6723    }
6724
6725    fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
6726        Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
6727    }
6728
6729    /// Drop state once the PR is finished and there is nothing to resume.
6730    pub fn clear_state(&self, number: i64) {
6731        let path = self.state_path(number);
6732        let _ = std::fs::remove_file(&path);
6733        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
6734    }
6735
6736    // -- housekeeping -----------------------------------------------------
6737
6738    /// Remove state files whose PR is merged or closed: the custody loop's
6739    /// state, the review record, and the check-in watermark alike. Grouped by
6740    /// number so each finished pull request costs one `gh` read however many
6741    /// files it left.
6742    pub fn prune_state(&self) -> Vec<String> {
6743        let base = self.root.join(STATE_DIR).join("state");
6744        let Ok(entries) = std::fs::read_dir(&base) else {
6745            return Vec::new();
6746        };
6747        let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
6748        for name in entries
6749            .flatten()
6750            .filter_map(|e| e.file_name().to_str().map(str::to_string))
6751        {
6752            if let Some(number) = state_file_number(&name) {
6753                by_number.entry(number).or_default().push(name);
6754            }
6755        }
6756
6757        let mut removed = Vec::new();
6758        for (number, mut names) in by_number {
6759            if !is_finished(&self.pr_state(number)) {
6760                continue;
6761            }
6762            names.sort();
6763            for name in names {
6764                let _ = std::fs::remove_file(base.join(&name));
6765                removed.push(format!("state {name}"));
6766            }
6767        }
6768        removed
6769    }
6770
6771    /// Delete state comments from PRs that are finished.
6772    ///
6773    /// Open PRs are left alone: their state may still be live.
6774    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
6775        #[derive(Deserialize)]
6776        struct Row {
6777            number: i64,
6778        }
6779        let numbers = match numbers {
6780            Some(numbers) => numbers,
6781            None => {
6782                let listed: Result<Vec<i64>> = (|| {
6783                    let text = self.gh(&[
6784                        "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
6785                    ])?;
6786                    let rows = serde_json::from_str::<Vec<Row>>(text.trim())
6787                        .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
6788                    Ok(rows.into_iter().map(|row| row.number).collect())
6789                })();
6790                match self.record_failed_write(listed) {
6791                    Ok(numbers) => numbers,
6792                    Err(e) => {
6793                        logdim!("could not inspect pull requests for state cleanup: {e}");
6794                        return Vec::new();
6795                    }
6796                }
6797            }
6798        };
6799
6800        let mut removed = Vec::new();
6801        for number in numbers {
6802            let state = match self.record_failed_write(self.try_pr_state(number)) {
6803                Ok(state) => state,
6804                Err(e) => {
6805                    logdim!("could not inspect PR #{number} for state cleanup: {e}");
6806                    continue;
6807                }
6808            };
6809            if !is_finished(&state) {
6810                continue;
6811            }
6812            let comments = match self.record_failed_write(self.try_state_comments(number)) {
6813                Ok(comments) => comments,
6814                Err(e) => {
6815                    logdim!("could not inspect state comments on PR #{number}: {e}");
6816                    continue;
6817                }
6818            };
6819            for (id, _) in comments {
6820                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6821                let deleted = self
6822                    .gh(&["api", "-X", "DELETE", &path, "--silent"])
6823                    .map(|_| ());
6824                match self.record_write(deleted) {
6825                    Ok(()) => removed.push(format!("state comment on PR #{number}")),
6826                    Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
6827                }
6828            }
6829        }
6830        removed
6831    }
6832
6833    /// Drop worktrees whose PR is finished, then the branches they left behind.
6834    ///
6835    /// With auto_merge off, which is the default, a run ends at "approved", so
6836    /// nothing would ever clean these up on its own and they accumulate one per
6837    /// run. A stranded worktree also holds its branch checked out, which makes
6838    /// a later `gh pr merge --delete-branch` fail to clean up.
6839    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
6840        let base = self.root.join(WORKTREE_DIR);
6841        let mut removed = Vec::new();
6842        let known = self.known_branches();
6843
6844        if let Ok(entries) = std::fs::read_dir(&base) {
6845            let mut names: Vec<String> = entries
6846                .flatten()
6847                .filter(|e| e.path().is_dir())
6848                .filter_map(|e| e.file_name().to_str().map(str::to_string))
6849                .collect();
6850            names.sort();
6851
6852            for name in names {
6853                // A read checkout is released by the run that made it, so one
6854                // still here was either kept for recovery or left by a run
6855                // that never got to release it.
6856                if let Some(owner) = read_checkout_owner(&name) {
6857                    let path = base.join(&name);
6858                    if force_all {
6859                        self.remove_worktree_at_force(&path);
6860                    } else if process_is_running(owner) {
6861                        continue;
6862                    } else if has_recovery_marker(&path) {
6863                        logdim!("kept {} for recovery", path.display());
6864                        continue;
6865                    } else {
6866                        self.discard_read_checkout(&path);
6867                    }
6868                    if !path.exists() {
6869                        removed.push(name);
6870                    }
6871                    continue;
6872                }
6873                // A review worktree is detached and owns no branch, so it is
6874                // tied to the pull request only by its directory name.
6875                if let Some(rest) = name.strip_prefix("review-") {
6876                    let number: i64 = rest.parse().unwrap_or(-1);
6877                    if !(force_all || is_finished(&self.pr_state(number))) {
6878                        continue;
6879                    }
6880                    let path = base.join(&name);
6881                    if force_all {
6882                        let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
6883                            if !belongs {
6884                                return Ok(false);
6885                            }
6886                            let local_ref = review_ref(number);
6887                            if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
6888                                return Ok(false);
6889                            }
6890                            let head = self.head_oid_checked(&path)?;
6891                            let recorded = self
6892                                .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
6893                                .trim()
6894                                .to_string();
6895                            Ok(head == recorded)
6896                        });
6897                        match owned {
6898                            Ok(true) => {}
6899                            Ok(false) => {
6900                                logdim!(
6901                                    "kept {} because no matching SPAR review reference proves \
6902                                     ownership",
6903                                    path.display()
6904                                );
6905                                continue;
6906                            }
6907                            Err(e) => {
6908                                logdim!(
6909                                    "kept {} because review ownership could not be verified: {}",
6910                                    path.display(),
6911                                    e.last_line()
6912                                );
6913                                continue;
6914                            }
6915                        }
6916                    } else {
6917                        if let Err(e) = self.refuse_review_worktree_changes(number) {
6918                            logdim!(
6919                                "kept {} because its review state could not be verified as \
6920                                 disposable: {}",
6921                                path.display(),
6922                                e.last_line()
6923                            );
6924                            continue;
6925                        }
6926                    }
6927                    if force_all {
6928                        if self.remove_worktree_at_force(&path) {
6929                            self.git_try(&["update-ref", "-d", &review_ref(number)]);
6930                        }
6931                    } else {
6932                        self.release_review_worktree(number);
6933                    }
6934                    if !path.exists() {
6935                        removed.push(name);
6936                    }
6937                    continue;
6938                }
6939                let branch = format!("{}{name}", self.branch_prefix);
6940                if !(force_all || self.worktree_is_done(&branch)) {
6941                    continue;
6942                }
6943                if !known.contains_key(&branch) {
6944                    logdim!("kept {branch} because it has no branch record");
6945                    continue;
6946                }
6947                let path = base.join(&name);
6948                if !force_all {
6949                    match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6950                        Ok(true) => {
6951                            logdim!(
6952                                "kept {} because it contains uncommitted changes or ignored files",
6953                                path.display()
6954                            );
6955                            continue;
6956                        }
6957                        Err(e) => {
6958                            logdim!(
6959                                "kept {} because its Git state could not be checked: {}",
6960                                path.display(),
6961                                e.last_line()
6962                            );
6963                            continue;
6964                        }
6965                        Ok(false) => {}
6966                    }
6967                    match self.branch_deletion_is_safe(&branch) {
6968                        Ok(true) => {}
6969                        Ok(false) => {
6970                            logdim!(
6971                                "kept {branch} because no surviving ref preserves its tip or \
6972                                 reflog-only commits"
6973                            );
6974                            continue;
6975                        }
6976                        Err(e) => {
6977                            logdim!(
6978                                "kept {branch} because preservation could not be verified: {}",
6979                                e.last_line()
6980                            );
6981                            continue;
6982                        }
6983                    }
6984                }
6985                let removed_worktree = if force_all {
6986                    self.remove_worktree_at_force(&path)
6987                } else {
6988                    match self.remove_worktree_at(&path) {
6989                        Ok(removed) => removed,
6990                        Err(error) => {
6991                            logdim!(
6992                                "kept {branch} and {} because removal did not reach a confirmed \
6993                                 quiet point: {}",
6994                                path.display(),
6995                                error.last_line()
6996                            );
6997                            false
6998                        }
6999                    }
7000                };
7001                if !removed_worktree {
7002                    continue;
7003                }
7004                if force_all {
7005                    self.git_try(&["branch", "-D", &branch]);
7006                    self.forget_branch(&branch);
7007                } else {
7008                    match self.delete_branch_if_safe(&branch) {
7009                        Ok(true) => self.forget_branch(&branch),
7010                        Ok(false) => logdim!(
7011                            "kept {branch} because its tip or reflog changed before deletion"
7012                        ),
7013                        Err(error) => logdim!(
7014                            "kept {branch} because deletion safety could not be rechecked: {}",
7015                            error.last_line()
7016                        ),
7017                    }
7018                }
7019                removed.push(name);
7020            }
7021        }
7022        removed.extend(self.prune_branches(force_all));
7023        removed
7024    }
7025
7026    /// Delete leftover branches spar created whose worktree is already gone.
7027    ///
7028    /// Deletion is driven by the ledger of branches spar actually created, not
7029    /// by a name pattern. Names default to `issue-N`, which is exactly what a
7030    /// person would call a branch themselves, so a name alone can never
7031    /// establish ownership. This is the data loss guard.
7032    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
7033        let known = self.known_branches();
7034        let branches: Vec<String> = known.keys().cloned().collect();
7035        if branches.is_empty() {
7036            return Vec::new();
7037        }
7038
7039        let checked_out: Vec<String> = self
7040            .git_try(&["worktree", "list", "--porcelain"])
7041            .lines()
7042            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
7043            .collect();
7044
7045        // %(refname:short) is ambiguous when a tag shares the branch name (it
7046        // yields "heads/..."), so take the full ref and strip it here.
7047        let existing: Vec<String> = self
7048            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
7049            .lines()
7050            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
7051            .collect();
7052
7053        let mut removed = Vec::new();
7054        for branch in branches {
7055            if !existing.contains(&branch) {
7056                self.forget_branch(&branch); // already gone, drop the record
7057                continue;
7058            }
7059            if checked_out.contains(&branch) {
7060                continue;
7061            }
7062            if !(force_all || self.worktree_is_done(&branch)) {
7063                continue;
7064            }
7065            if !force_all {
7066                let Some(_record) = known.get(&branch) else {
7067                    continue;
7068                };
7069                match self.branch_deletion_is_safe(&branch) {
7070                    Ok(true) => {}
7071                    Ok(false) => {
7072                        logdim!(
7073                            "kept {branch} because no surviving ref preserves its tip or \
7074                             reflog-only commits"
7075                        );
7076                        continue;
7077                    }
7078                    Err(e) => {
7079                        logdim!(
7080                            "kept {branch} because preservation could not be verified: {}",
7081                            e.last_line()
7082                        );
7083                        continue;
7084                    }
7085                }
7086            }
7087            let deleted = if force_all {
7088                self.git(&["branch", "-D", &branch]).map(|_| true)
7089            } else {
7090                self.delete_branch_if_safe(&branch)
7091            };
7092            match deleted {
7093                Ok(true) => {
7094                    self.forget_branch(&branch);
7095                    removed.push(format!("branch {branch}"));
7096                }
7097                Ok(false) => {
7098                    logdim!("kept {branch} because its tip or reflog changed before deletion");
7099                }
7100                Err(e) => {
7101                    // A branch that silently survives pruning looks like a spar
7102                    // bug, so the name and git's own reason have to be said.
7103                    logdim!("could not delete {branch}: {}", e.last_line());
7104                }
7105            }
7106        }
7107        removed
7108    }
7109
7110    /// True when the PR behind this branch is merged or closed.
7111    fn worktree_is_done(&self, branch: &str) -> bool {
7112        #[derive(Deserialize)]
7113        struct Row {
7114            state: String,
7115        }
7116        let entry = branch
7117            .strip_prefix(self.branch_prefix.as_str())
7118            .unwrap_or(branch);
7119        if let Some(rest) = entry.strip_prefix("pr-") {
7120            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
7121        }
7122        // A split part is the same shape as an issue branch: one branch, whose
7123        // pull requests say whether it is finished. Without it here, a part
7124        // branch is one nothing but `clean --all` would ever remove.
7125        if entry.starts_with("issue-") || entry.starts_with("split-") {
7126            let text = self.gh_try(&[
7127                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
7128            ]);
7129            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
7130            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
7131        }
7132        false
7133    }
7134}
7135
7136// ---------------------------------------------------------------------------
7137// Free helpers
7138// ---------------------------------------------------------------------------
7139
7140/// Take a read-only probe again before treating its failure as evidence.
7141///
7142/// Every probe is a Git invocation under a thirty second cap plus a walk over a
7143/// tree an agent may still be building in. A probe that fails proves nothing
7144/// about the worktree, and there is a real difference between "the tree moved"
7145/// and "the question could not be asked just then", which the caller turns into
7146/// the same run-ending error.
7147pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
7148    match probe() {
7149        Ok(value) => Ok(value),
7150        Err(first) => {
7151            logdim!("retrying a worktree probe after: {}", first.last_line());
7152            probe()
7153        }
7154    }
7155}
7156
7157/// Read attribute files without asking Git to inspect working-tree content.
7158///
7159/// A newly written attribute can select a clean or smudge filter. It must be
7160/// detected before a post-call status, diff, or add command has a chance to run
7161/// that filter in the parent process.
7162pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
7163    let root = std::fs::canonicalize(cwd)
7164        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7165    let mut files = BTreeMap::new();
7166    let mut visited = BTreeSet::new();
7167    collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
7168    Ok(AttributeState { files })
7169}
7170
7171fn collect_attribute_files(
7172    root: &Path,
7173    repository: &Path,
7174    prefix: &Path,
7175    visited: &mut BTreeSet<PathBuf>,
7176    files: &mut BTreeMap<PathBuf, [u8; 32]>,
7177) -> Result<()> {
7178    let canonical = std::fs::canonicalize(repository)
7179        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7180    if !visited.insert(canonical) {
7181        bail!("submodule recursion revisited {}", repository.display());
7182    }
7183    let entries = index_entries(repository)?;
7184    let mut paths: BTreeSet<PathBuf> = entries
7185        .iter()
7186        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7187        .map(|entry| entry.path.clone())
7188        .collect();
7189    // Ignored on purpose. A `.gitattributes` under build output can only select
7190    // filters for paths that are themselves ignored, and no ignored path is
7191    // ever staged or compared as tracked content, so it cannot reach a managed
7192    // commit. Capturing it meant `cargo package` or `npm ci` writing one into
7193    // its own output ended a read-only call outright.
7194    paths.extend(untracked_attribute_paths(repository)?);
7195    for path in paths {
7196        let from_root = prefix.join(&path);
7197        let state = attribute_file_fingerprint(&root.join(&from_root))?;
7198        files.insert(from_root, state);
7199    }
7200    for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
7201        let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
7202            continue;
7203        };
7204        collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
7205    }
7206    Ok(())
7207}
7208
7209/// Leave a visible, untracked reason ordinary cleanup can detect on a later
7210/// run even when Git status normalizes the original working-file change away.
7211pub(crate) fn uncertain_worktree_change(
7212    cwd: &Path,
7213    message: impl Into<String>,
7214) -> crate::error::SparError {
7215    let message = message.into();
7216    let marker = write_recovery_marker(cwd, &message);
7217    let note = match marker {
7218        Ok(path) => format!(" Recovery marker: {}.", path.display()),
7219        Err(e) => format!(
7220            " A recovery marker could not be written: {}.",
7221            e.last_line()
7222        ),
7223    };
7224    crate::error::SparError::uncertain_write(format!("{message}{note}"))
7225}
7226
7227fn has_recovery_marker(cwd: &Path) -> bool {
7228    let Ok(entries) = std::fs::read_dir(cwd) else {
7229        return false;
7230    };
7231    entries.flatten().any(|entry| {
7232        entry
7233            .file_name()
7234            .to_str()
7235            .is_some_and(|name| name.starts_with(RECOVERY_MARKER_PREFIX))
7236    })
7237}
7238
7239/// The process a read checkout's directory name says made it.
7240fn read_checkout_owner(name: &str) -> Option<u32> {
7241    let rest = name.strip_prefix(READ_CHECKOUT_PREFIX)?;
7242    let (pid, serial) = rest.split_once('-')?;
7243    serial.parse::<usize>().ok()?;
7244    pid.parse().ok()
7245}
7246
7247/// Whether a process may still be running.
7248///
7249/// Anything short of proof that it is gone says yes. A wrong yes leaves a
7250/// checkout for `spar clean --all`; a wrong no deletes one under a running
7251/// call.
7252#[cfg(unix)]
7253fn process_is_running(pid: u32) -> bool {
7254    let Ok(pid) = libc::pid_t::try_from(pid) else {
7255        return true;
7256    };
7257    if pid <= 0 {
7258        return true;
7259    }
7260    if unsafe { libc::kill(pid, 0) } == 0 {
7261        return true;
7262    }
7263    std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
7264}
7265
7266#[cfg(not(unix))]
7267fn process_is_running(_pid: u32) -> bool {
7268    true
7269}
7270
7271fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
7272    use std::sync::atomic::{AtomicU32, Ordering};
7273    static NEXT: AtomicU32 = AtomicU32::new(0);
7274    for _ in 0..1000 {
7275        let serial = NEXT.fetch_add(1, Ordering::Relaxed);
7276        let path = cwd.join(format!(
7277            "{RECOVERY_MARKER_PREFIX}{}-{serial}",
7278            std::process::id()
7279        ));
7280        let mut options = OpenOptions::new();
7281        options.write(true).create_new(true);
7282        #[cfg(unix)]
7283        {
7284            use std::os::unix::fs::OpenOptionsExt;
7285            options.mode(0o600);
7286        }
7287        match options.open(&path) {
7288            Ok(mut file) => {
7289                file.write_all(detail.as_bytes())
7290                    .and_then(|_| file.write_all(b"\n"))
7291                    .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
7292                return Ok(path);
7293            }
7294            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
7295            Err(e) => {
7296                return Err(spar_err!(
7297                    "could not create a recovery marker in {}: {e}",
7298                    cwd.display()
7299                ))
7300            }
7301        }
7302    }
7303    bail!(
7304        "could not choose a free recovery marker name in {}",
7305        cwd.display()
7306    )
7307}
7308
7309/// Build a Git command that cannot launch automatic repository maintenance.
7310///
7311/// A fetch may otherwise prune missing linked worktree registrations. SPAR
7312/// must only remove registrations it has proven it owns.
7313fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
7314    let mut argv = vec![
7315        "git".to_string(),
7316        "-c".to_string(),
7317        "maintenance.auto=false".to_string(),
7318        "-c".to_string(),
7319        "gc.auto=0".to_string(),
7320    ];
7321    argv.extend(args.iter().map(|arg| (*arg).to_string()));
7322    argv
7323}
7324
7325fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
7326    let mut argv = git_without_maintenance_argv(&[]);
7327    argv.extend([
7328        "-c".to_string(),
7329        "core.fsmonitor=".to_string(),
7330        "-c".to_string(),
7331        "commit.gpgsign=false".to_string(),
7332        "-c".to_string(),
7333        "core.hooksPath=/dev/null".to_string(),
7334    ]);
7335    argv.extend(args.iter().map(|arg| (*arg).to_string()));
7336    argv
7337}
7338
7339/// Snapshot every untracked file, including ignored files, without changing
7340/// path bytes.
7341///
7342/// Without an exclude option, Git lists both ordinary and ignored untracked
7343/// entries. Metadata fingerprints make overwriting an existing path observable
7344/// without hashing a potentially multi-gigabyte build tree on every call.
7345pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
7346    let root = std::fs::canonicalize(cwd)
7347        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7348    let mut files = BTreeMap::new();
7349    let mut ignored = BTreeSet::new();
7350    let mut visited = BTreeSet::new();
7351    collect_untracked_files(
7352        &root,
7353        &root,
7354        Path::new(""),
7355        &mut visited,
7356        &mut files,
7357        &mut ignored,
7358    )?;
7359    Ok(IgnoredState { files, ignored })
7360}
7361
7362fn collect_untracked_files(
7363    root: &Path,
7364    repository: &Path,
7365    prefix: &Path,
7366    visited: &mut BTreeSet<PathBuf>,
7367    files: &mut BTreeMap<PathBuf, UntrackedFile>,
7368    ignored: &mut BTreeSet<PathBuf>,
7369) -> Result<()> {
7370    let canonical = std::fs::canonicalize(repository)
7371        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7372    if !visited.insert(canonical.clone()) {
7373        bail!("submodule recursion revisited {}", canonical.display());
7374    }
7375    // Classify ordinary paths in the listing itself. Listing every path here
7376    // and marking ignored paths in a second query misclassifies build output
7377    // deleted between queries as ordinary untracked work.
7378    let listed = run_git_bytes(
7379        repository,
7380        &["ls-files", "--others", "--exclude-standard", "-z"],
7381    )?;
7382    if !listed.is_empty() && !listed.ends_with(&[0]) {
7383        bail!(
7384            "git returned an unterminated untracked-file list for {}",
7385            repository.display()
7386        );
7387    }
7388
7389    for raw in listed
7390        .split(|byte| *byte == 0)
7391        .filter(|raw| !raw.is_empty())
7392    {
7393        let (relative, nested) = untracked_record(raw, "untracked")?;
7394        let from_root = prefix.join(&relative);
7395        if is_spar_own_path(&from_root) {
7396            continue;
7397        }
7398        let absolute = root.join(&from_root);
7399        let fingerprint = if nested {
7400            nested_repository_fingerprint(&absolute)?
7401        } else {
7402            ignored_file_fingerprint(&absolute)?
7403        };
7404        if files.insert(from_root.clone(), fingerprint).is_some() {
7405            bail!(
7406                "git returned the untracked path more than once: {:?}",
7407                from_root
7408            );
7409        }
7410    }
7411
7412    let ignored_listed = run_git_bytes(
7413        repository,
7414        &[
7415            "ls-files",
7416            "--others",
7417            "--ignored",
7418            "--exclude-standard",
7419            "-z",
7420        ],
7421    )?;
7422    if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
7423        bail!(
7424            "git returned an unterminated ignored-file list for {}",
7425            repository.display()
7426        );
7427    }
7428    // Two `ls-files` runs cannot be made to agree about a tree that is being
7429    // built in. A path named only by the second one was created between them,
7430    // which is a fact about the moment, not a corruption: record it and let the
7431    // comparison decide, rather than ending a run that had nothing wrong with
7432    // it.
7433    for raw in ignored_listed
7434        .split(|byte| *byte == 0)
7435        .filter(|raw| !raw.is_empty())
7436    {
7437        let (relative, nested) = untracked_record(raw, "ignored")?;
7438        let from_root = prefix.join(relative);
7439        if is_spar_own_path(&from_root) {
7440            continue;
7441        }
7442        if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
7443            let absolute = root.join(&from_root);
7444            let fingerprint = if nested {
7445                nested_repository_fingerprint(&absolute)?
7446            } else {
7447                ignored_file_fingerprint(&absolute)?
7448            };
7449            slot.insert(fingerprint);
7450        }
7451        if !ignored.insert(from_root.clone()) {
7452            bail!(
7453                "git returned the ignored path more than once: {:?}",
7454                from_root
7455            );
7456        }
7457    }
7458
7459    for link in gitlinks(repository)? {
7460        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7461            continue;
7462        };
7463        collect_untracked_files(
7464            root,
7465            &submodule,
7466            &prefix.join(&link.path),
7467            visited,
7468            files,
7469            ignored,
7470        )?;
7471    }
7472    Ok(())
7473}
7474
7475fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
7476    let argv = git_without_automation_argv(args);
7477    proc::run_bytes(
7478        &argv,
7479        &ExecOpts::new()
7480            .cwd(cwd)
7481            .timeout_secs(30)
7482            .stop_descendants(true),
7483    )
7484}
7485
7486fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
7487    let argv = git_without_automation_argv(args);
7488    proc::run(
7489        &argv,
7490        &ExecOpts::new()
7491            .cwd(cwd)
7492            .timeout_secs(30)
7493            .stop_descendants(true),
7494    )
7495}
7496
7497fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
7498    let path = path.to_str().ok_or_else(|| {
7499        spar_err!(
7500            "cannot verify filtered content for a non-UTF-8 path in {}",
7501            cwd.display()
7502        )
7503    })?;
7504    let path_arg = format!("--path={path}");
7505    let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
7506    Ok(Sha256::digest(bytes).into())
7507}
7508
7509fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
7510    let relative = path_from_git_bytes(raw)?;
7511    if relative.is_absolute()
7512        || relative.components().any(|component| {
7513            matches!(
7514                component,
7515                std::path::Component::ParentDir
7516                    | std::path::Component::RootDir
7517                    | std::path::Component::Prefix(_)
7518            )
7519        })
7520    {
7521        bail!("git returned an unsafe {kind} path: {:?}", relative);
7522    }
7523    Ok(relative)
7524}
7525
7526/// Split one `ls-files --others` record into its path and whether Git reported
7527/// a nested repository rather than a single file.
7528///
7529/// Git never lists the contents of a repository inside the working tree, so a
7530/// checkout parked there, such as another of SPAR's own worktrees, arrives as
7531/// one record for the directory itself ending in a separator. Git writes that
7532/// separator on every platform. Trimming it keeps the recorded path equal to
7533/// the same path seen any other way.
7534fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
7535    let nested = raw.last() == Some(&b'/');
7536    let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
7537    if trimmed.is_empty() {
7538        bail!("git returned an empty {kind} path");
7539    }
7540    Ok((safe_git_path(trimmed, kind)?, nested))
7541}
7542
7543fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
7544    let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
7545    if !listed.is_empty() && !listed.ends_with(&[0]) {
7546        bail!(
7547            "git returned an unterminated index listing for {}",
7548            cwd.display()
7549        );
7550    }
7551    let mut entries = Vec::new();
7552    for record in listed
7553        .split(|byte| *byte == 0)
7554        .filter(|record| !record.is_empty())
7555    {
7556        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7557            bail!(
7558                "git returned a malformed index record for {}",
7559                cwd.display()
7560            );
7561        };
7562        let header = &record[..tab];
7563        let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
7564        if fields.len() != 3 {
7565            bail!(
7566                "git returned a malformed index header for {}",
7567                cwd.display()
7568            );
7569        }
7570        if fields[2] != b"0" {
7571            continue;
7572        }
7573        let mode = std::str::from_utf8(fields[0])
7574            .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
7575            .to_string();
7576        let oid = std::str::from_utf8(fields[1])
7577            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7578            .to_string();
7579        entries.push(IndexEntry {
7580            path: safe_git_path(&record[tab + 1..], "index")?,
7581            mode,
7582            oid,
7583        });
7584    }
7585    Ok(entries)
7586}
7587
7588/// Share Git's complete ignore policy between call checkpoints and handoffs.
7589fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
7590    let listed = run_git_bytes(
7591        cwd,
7592        &[
7593            "ls-files",
7594            "--others",
7595            "--exclude-standard",
7596            "-z",
7597            "--",
7598            ".gitattributes",
7599            ":(glob)**/.gitattributes",
7600        ],
7601    )?;
7602    if !listed.is_empty() && !listed.ends_with(&[0]) {
7603        bail!(
7604            "git returned an unterminated attribute-file listing for {}",
7605            cwd.display()
7606        );
7607    }
7608    listed
7609        .split(|byte| *byte == 0)
7610        .filter(|record| !record.is_empty())
7611        .map(|raw| safe_git_path(raw, "attribute"))
7612        .collect()
7613}
7614
7615fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
7616    if !untracked_attribute_paths(cwd)?.is_empty() {
7617        return Ok(true);
7618    }
7619
7620    let index = index_entries(cwd)?
7621        .into_iter()
7622        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7623        .map(|entry| (entry.path, (entry.mode, entry.oid)))
7624        .collect::<BTreeMap<_, _>>();
7625    let head = tree_entries(cwd, "HEAD")?
7626        .into_iter()
7627        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7628        .map(|entry| (entry.path, (entry.mode, entry.oid)))
7629        .collect::<BTreeMap<_, _>>();
7630    if index != head {
7631        return Ok(true);
7632    }
7633
7634    let effective = check_attributes(cwd, index.keys().cloned())?;
7635    let config = CheckoutConfig::read(cwd)?;
7636    for (path, (_mode, oid)) in index {
7637        let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
7638            return Ok(true);
7639        };
7640        let attributes = effective
7641            .get(&path)
7642            .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
7643        if allows_expected_crlf(&config, attributes)? {
7644            if worktree.mode == "120000" {
7645                return Ok(true);
7646            }
7647            let (normalized, every_lf_was_crlf) =
7648                normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
7649            if !every_lf_was_crlf || normalized != oid {
7650                return Ok(true);
7651            }
7652        } else if worktree.raw_oid != oid {
7653            return Ok(true);
7654        }
7655    }
7656    Ok(false)
7657}
7658
7659fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
7660    Ok(index_entries(cwd)?
7661        .into_iter()
7662        .filter(|entry| entry.mode == "160000")
7663        .map(|entry| Gitlink {
7664            path: entry.path,
7665            oid: entry.oid,
7666        })
7667        .collect())
7668}
7669
7670fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
7671    let mut tracked = BTreeMap::new();
7672    for entry in index_entries(cwd)? {
7673        if entry.mode == "160000" {
7674            continue;
7675        }
7676        let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
7677        tracked.insert(
7678            entry.path,
7679            TrackedEntry {
7680                index_mode: entry.mode,
7681                index_oid: entry.oid,
7682                worktree,
7683            },
7684        );
7685    }
7686    Ok(tracked)
7687}
7688
7689fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
7690    let metadata = match std::fs::symlink_metadata(path) {
7691        Ok(metadata) => metadata,
7692        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7693        Err(e) => {
7694            return Err(spar_err!(
7695                "could not inspect tracked file {}: {e}",
7696                path.display()
7697            ))
7698        }
7699    };
7700    let mut fingerprint = Sha256::new();
7701    if metadata.file_type().is_symlink() {
7702        let target = std::fs::read_link(path)
7703            .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
7704        let bytes = os_str_bytes(target.as_os_str())?;
7705        fingerprint.update(b"symlink\0");
7706        fingerprint.update(&bytes);
7707        let content = Sha256::digest(&bytes).into();
7708        return Ok(Some(WorktreeFile {
7709            mode: "120000".to_string(),
7710            #[cfg(unix)]
7711            permissions: 0,
7712            raw_oid: git_blob_oid(oid_len, &bytes)?,
7713            fingerprint: fingerprint.finalize().into(),
7714            content,
7715        }));
7716    }
7717    if !metadata.is_file() {
7718        bail!("tracked path {} is not a file or symlink", path.display());
7719    }
7720
7721    let mut options = OpenOptions::new();
7722    options.read(true);
7723    #[cfg(unix)]
7724    {
7725        use std::os::unix::fs::OpenOptionsExt;
7726        options.custom_flags(libc::O_NOFOLLOW);
7727    }
7728    let mut file = options
7729        .open(path)
7730        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7731    let before = file
7732        .metadata()
7733        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7734    let mode = tracked_file_mode(&before);
7735    #[cfg(unix)]
7736    let permissions = {
7737        use std::os::unix::fs::MetadataExt;
7738        before.mode() & 0o7777
7739    };
7740    fingerprint.update(b"file\0");
7741    fingerprint.update(mode.as_bytes());
7742    #[cfg(unix)]
7743    fingerprint.update(permissions.to_le_bytes());
7744    fingerprint.update(before.len().to_le_bytes());
7745    let mut content = Sha256::new();
7746    let header = format!("blob {}\0", before.len());
7747    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7748    let mut buf = [0u8; 64 * 1024];
7749    loop {
7750        let read = file
7751            .read(&mut buf)
7752            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7753        if read == 0 {
7754            break;
7755        }
7756        fingerprint.update(&buf[..read]);
7757        content.update(&buf[..read]);
7758        object.update(&buf[..read]);
7759    }
7760    let after = file
7761        .metadata()
7762        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7763    if before.len() != after.len()
7764        || before.modified().ok() != after.modified().ok()
7765        || before.permissions() != after.permissions()
7766    {
7767        bail!(
7768            "tracked file {} changed while it was being inspected",
7769            path.display()
7770        );
7771    }
7772    let current = std::fs::symlink_metadata(path)
7773        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7774    if !same_file(&after, &current) {
7775        bail!(
7776            "tracked file {} was replaced while it was being inspected",
7777            path.display()
7778        );
7779    }
7780    Ok(Some(WorktreeFile {
7781        mode,
7782        #[cfg(unix)]
7783        permissions,
7784        raw_oid: object.finish(),
7785        fingerprint: fingerprint.finalize().into(),
7786        content: content.finalize().into(),
7787    }))
7788}
7789
7790fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
7791    let metadata = std::fs::symlink_metadata(path)
7792        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7793    let mut digest = Sha256::new();
7794    if metadata.file_type().is_symlink() {
7795        digest.update(b"symlink\0");
7796        let target = std::fs::read_link(path)
7797            .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
7798        digest.update(os_str_bytes(target.as_os_str())?);
7799        return Ok(digest.finalize().into());
7800    }
7801    if !metadata.is_file() {
7802        bail!("attribute path {} is not a file or symlink", path.display());
7803    }
7804    let mut options = OpenOptions::new();
7805    options.read(true);
7806    #[cfg(unix)]
7807    {
7808        use std::os::unix::fs::OpenOptionsExt;
7809        options.custom_flags(libc::O_NOFOLLOW);
7810    }
7811    let mut file = options
7812        .open(path)
7813        .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7814    let before = file
7815        .metadata()
7816        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7817    digest.update(b"file\0");
7818    let mut buf = [0u8; 64 * 1024];
7819    loop {
7820        let read = file
7821            .read(&mut buf)
7822            .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7823        if read == 0 {
7824            break;
7825        }
7826        digest.update(&buf[..read]);
7827    }
7828    let after = file
7829        .metadata()
7830        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7831    let current = std::fs::symlink_metadata(path)
7832        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7833    if before.len() != after.len()
7834        || before.modified().ok() != after.modified().ok()
7835        || !same_file(&after, &current)
7836    {
7837        bail!(
7838            "attribute file {} changed while it was being inspected",
7839            path.display()
7840        );
7841    }
7842    Ok(digest.finalize().into())
7843}
7844
7845enum ObjectHasher {
7846    Sha1(Sha1),
7847    Sha256(Sha256),
7848}
7849
7850impl ObjectHasher {
7851    fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
7852        let mut hasher = match oid_len {
7853            40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
7854            64 => Self::Sha256(Sha256::new()),
7855            _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
7856        };
7857        hasher.update(header);
7858        Ok(hasher)
7859    }
7860
7861    fn update(&mut self, bytes: &[u8]) {
7862        match self {
7863            Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
7864            Self::Sha256(hasher) => hasher.update(bytes),
7865        }
7866    }
7867
7868    fn finish(self) -> String {
7869        let bytes = match self {
7870            Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
7871            Self::Sha256(hasher) => hasher.finalize().to_vec(),
7872        };
7873        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
7874    }
7875}
7876
7877fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
7878    let header = format!("blob {}\0", bytes.len());
7879    let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
7880    hasher.update(bytes);
7881    Ok(hasher.finish())
7882}
7883
7884fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
7885    let mut first = open_regular_file(path)?;
7886    let first_before = first
7887        .metadata()
7888        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7889    let mut raw_len = 0u64;
7890    let mut crlf_pairs = 0u64;
7891    let mut previous_was_cr = false;
7892    let mut every_lf_was_crlf = true;
7893    let mut buf = [0u8; 64 * 1024];
7894    loop {
7895        let read = first
7896            .read(&mut buf)
7897            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7898        if read == 0 {
7899            break;
7900        }
7901        raw_len = raw_len
7902            .checked_add(read as u64)
7903            .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
7904        for byte in &buf[..read] {
7905            if *byte == b'\n' {
7906                if previous_was_cr {
7907                    crlf_pairs += 1;
7908                } else {
7909                    every_lf_was_crlf = false;
7910                }
7911            }
7912            previous_was_cr = *byte == b'\r';
7913        }
7914    }
7915    let first_after = first
7916        .metadata()
7917        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7918    let current = std::fs::symlink_metadata(path)
7919        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7920    if raw_len != first_before.len()
7921        || !stable_file_metadata(&first_before, &first_after)
7922        || !stable_file_metadata(&first_after, &current)
7923    {
7924        bail!(
7925            "tracked file {} changed while line endings were inspected",
7926            path.display()
7927        );
7928    }
7929
7930    let normalized_len = raw_len
7931        .checked_sub(crlf_pairs)
7932        .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
7933    let header = format!("blob {normalized_len}\0");
7934    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7935    let mut second = open_regular_file(path)?;
7936    let second_before = second
7937        .metadata()
7938        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7939    if !stable_file_metadata(&first_after, &second_before) {
7940        bail!(
7941            "tracked file {} changed between line-ending checks",
7942            path.display()
7943        );
7944    }
7945    let mut pending_cr = false;
7946    loop {
7947        let read = second
7948            .read(&mut buf)
7949            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7950        if read == 0 {
7951            break;
7952        }
7953        for byte in &buf[..read] {
7954            if pending_cr {
7955                if *byte == b'\n' {
7956                    object.update(b"\n");
7957                    pending_cr = false;
7958                    continue;
7959                }
7960                object.update(b"\r");
7961                pending_cr = false;
7962            }
7963            if *byte == b'\r' {
7964                pending_cr = true;
7965            } else {
7966                object.update(std::slice::from_ref(byte));
7967            }
7968        }
7969    }
7970    if pending_cr {
7971        object.update(b"\r");
7972    }
7973    let second_after = second
7974        .metadata()
7975        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7976    let current = std::fs::symlink_metadata(path)
7977        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7978    if !stable_file_metadata(&second_before, &second_after)
7979        || !stable_file_metadata(&second_after, &current)
7980    {
7981        bail!(
7982            "tracked file {} changed while line endings were hashed",
7983            path.display()
7984        );
7985    }
7986    Ok((object.finish(), every_lf_was_crlf))
7987}
7988
7989fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7990    let mut options = OpenOptions::new();
7991    options.read(true);
7992    #[cfg(unix)]
7993    {
7994        use std::os::unix::fs::OpenOptionsExt;
7995        options.custom_flags(libc::O_NOFOLLOW);
7996    }
7997    let file = options
7998        .open(path)
7999        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
8000    let metadata = file
8001        .metadata()
8002        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
8003    if !metadata.is_file() {
8004        bail!("tracked path {} is not a regular file", path.display());
8005    }
8006    Ok(file)
8007}
8008
8009fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8010    if !same_file(left, right)
8011        || left.len() != right.len()
8012        || left.modified().ok() != right.modified().ok()
8013        || left.permissions() != right.permissions()
8014    {
8015        return false;
8016    }
8017    #[cfg(unix)]
8018    {
8019        use std::os::unix::fs::MetadataExt;
8020        left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
8021    }
8022    #[cfg(not(unix))]
8023    {
8024        left.created().ok() == right.created().ok()
8025    }
8026}
8027
8028/// How many paths one `check-attr` call is asked about.
8029///
8030/// `check-attr` answers with three NUL fields per attribute per path, so the
8031/// output is several times the input. A whole monorepo in one call is megabytes
8032/// in both directions, and the 30 second timeout below is a timeout on the
8033/// batch. Batching keeps each call small enough to be honest about.
8034const ATTRIBUTE_BATCH: usize = 1000;
8035
8036fn check_attributes(
8037    cwd: &Path,
8038    paths: impl IntoIterator<Item = PathBuf>,
8039) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
8040    let paths = paths.into_iter().collect::<BTreeSet<_>>();
8041    if paths.is_empty() {
8042        return Ok(BTreeMap::new());
8043    }
8044    let ordered = paths.into_iter().collect::<Vec<_>>();
8045    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
8046    for batch in ordered.chunks(ATTRIBUTE_BATCH) {
8047        values.extend(check_attribute_batch(cwd, batch)?);
8048    }
8049    Ok(values)
8050}
8051
8052fn check_attribute_batch(
8053    cwd: &Path,
8054    batch: &[PathBuf],
8055) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
8056    const NAMES: [&str; 6] = [
8057        "filter",
8058        "working-tree-encoding",
8059        "ident",
8060        "text",
8061        "eol",
8062        "crlf",
8063    ];
8064    let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
8065    let mut input = String::new();
8066    for path in &paths {
8067        let path = path.to_str().ok_or_else(|| {
8068            spar_err!(
8069                "cannot inspect attributes for a non-UTF-8 path in {}",
8070                cwd.display()
8071            )
8072        })?;
8073        input.push_str(path);
8074        input.push('\0');
8075    }
8076    let argv = git_without_automation_argv(&[
8077        "check-attr",
8078        "-z",
8079        "--cached",
8080        "--stdin",
8081        "filter",
8082        "working-tree-encoding",
8083        "ident",
8084        "text",
8085        "eol",
8086        "crlf",
8087    ]);
8088    let output = proc::run_bytes(
8089        &argv,
8090        &ExecOpts::new()
8091            .cwd(cwd)
8092            .timeout_secs(30)
8093            .stdin(input)
8094            .stop_descendants(true),
8095    )?;
8096    if !output.is_empty() && !output.ends_with(&[0]) {
8097        bail!(
8098            "git returned an unterminated attribute result for {}",
8099            cwd.display()
8100        );
8101    }
8102    let fields = output
8103        .split(|byte| *byte == 0)
8104        .filter(|field| !field.is_empty())
8105        .collect::<Vec<_>>();
8106    if fields.len() != paths.len() * NAMES.len() * 3 {
8107        bail!(
8108            "git returned an unexpected attribute result for {}",
8109            cwd.display()
8110        );
8111    }
8112    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
8113    for record in fields.chunks_exact(3) {
8114        let path = safe_git_path(record[0], "attribute")?;
8115        if !paths.contains(&path) {
8116            bail!(
8117                "git returned attributes for the wrong path in {}",
8118                cwd.display()
8119            );
8120        }
8121        let name = std::str::from_utf8(record[1])
8122            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
8123        let value = std::str::from_utf8(record[2])
8124            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
8125        values
8126            .entry(path)
8127            .or_default()
8128            .insert(name.to_string(), value.to_string());
8129    }
8130    if paths.iter().any(|path| {
8131        values
8132            .get(path)
8133            .is_none_or(|attributes| attributes.len() != NAMES.len())
8134    }) {
8135        bail!(
8136            "git omitted an attribute result for a tracked path in {}",
8137            cwd.display()
8138        );
8139    }
8140    Ok(values)
8141}
8142
8143fn attribute_is_active(value: Option<&String>) -> bool {
8144    !matches!(
8145        value.map(String::as_str),
8146        None | Some("unspecified") | Some("unset")
8147    )
8148}
8149
8150fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
8151    attribute_is_active(values.get("filter"))
8152        || attribute_is_active(values.get("working-tree-encoding"))
8153}
8154
8155/// The checkout settings a per-file line-ending decision depends on.
8156///
8157/// These are constant for a worktree, but the checks that read them run once
8158/// per tracked file, and each read was its own `git config` process. A
8159/// thousand-file repository with no `.gitattributes` takes every one of those
8160/// branches, which is half a minute of process spawning per scan, and the sweep
8161/// before a run scans every finished worktree more than once. Read them here,
8162/// once, and hand them down.
8163struct CheckoutConfig {
8164    autocrlf: Option<String>,
8165    eol: Option<String>,
8166    symlinks: Option<bool>,
8167}
8168
8169impl CheckoutConfig {
8170    fn read(cwd: &Path) -> Result<Self> {
8171        Ok(Self {
8172            autocrlf: git_config_value(cwd, "core.autocrlf")?,
8173            eol: git_config_value(cwd, "core.eol")?,
8174            symlinks: git_config_bool(cwd, "core.symlinks")?,
8175        })
8176    }
8177
8178    fn autocrlf_is_true(&self) -> bool {
8179        self.autocrlf.as_ref().is_some_and(|value| {
8180            matches!(
8181                value.to_ascii_lowercase().as_str(),
8182                "true" | "yes" | "on" | "1"
8183            )
8184        })
8185    }
8186
8187    fn eol_is(&self, wanted: &str) -> bool {
8188        self.eol
8189            .as_ref()
8190            .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
8191    }
8192}
8193
8194fn path_has_ambiguous_transform(
8195    config: &CheckoutConfig,
8196    values: &BTreeMap<String, String>,
8197) -> Result<bool> {
8198    if path_has_external_transform(values)
8199        || attribute_is_active(values.get("ident"))
8200        || attribute_is_active(values.get("crlf"))
8201    {
8202        return Ok(true);
8203    }
8204    let text = values.get("text").map(String::as_str);
8205    let eol = values.get("eol").map(String::as_str);
8206    if text == Some("auto") {
8207        return Ok(true);
8208    }
8209    if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
8210        || !matches!(
8211            eol,
8212            Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
8213        )
8214    {
8215        return Ok(true);
8216    }
8217    if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
8218        return Ok(config.autocrlf_is_true());
8219    }
8220    Ok(false)
8221}
8222
8223fn allows_expected_crlf(
8224    config: &CheckoutConfig,
8225    values: &BTreeMap<String, String>,
8226) -> Result<bool> {
8227    if path_has_external_transform(values)
8228        || attribute_is_active(values.get("ident"))
8229        || attribute_is_active(values.get("crlf"))
8230    {
8231        return Ok(false);
8232    }
8233    let text = values.get("text").map(String::as_str);
8234    let eol = values.get("eol").map(String::as_str);
8235    if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
8236        return Ok(false);
8237    }
8238    if eol == Some("crlf") {
8239        return Ok(true);
8240    }
8241    if text != Some("set") {
8242        return Ok(false);
8243    }
8244    if let Some(autocrlf) = config.autocrlf.as_deref() {
8245        match autocrlf.to_ascii_lowercase().as_str() {
8246            "true" | "yes" | "on" | "1" => return Ok(true),
8247            "input" => return Ok(false),
8248            _ => {}
8249        }
8250    }
8251    if config.eol_is("crlf") {
8252        return Ok(true);
8253    }
8254    #[cfg(windows)]
8255    if config.eol.is_none() || config.eol_is("native") {
8256        return Ok(true);
8257    }
8258    Ok(false)
8259}
8260
8261fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
8262    let argv = git_without_automation_argv(&["config", "--get", key]);
8263    let output = proc::exec(
8264        &argv,
8265        &ExecOpts::new()
8266            .cwd(cwd)
8267            .timeout_secs(30)
8268            .check(false)
8269            .stop_descendants(true),
8270    )?;
8271    match output.code {
8272        0 => Ok(Some(output.stdout.trim().to_string())),
8273        1 => Ok(None),
8274        _ => bail!(
8275            "could not read Git configuration in {}: {}",
8276            cwd.display(),
8277            output.stderr.trim()
8278        ),
8279    }
8280}
8281
8282fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
8283    let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
8284    let output = proc::exec(
8285        &argv,
8286        &ExecOpts::new()
8287            .cwd(cwd)
8288            .timeout_secs(30)
8289            .check(false)
8290            .stop_descendants(true),
8291    )?;
8292    match output.code {
8293        0 if output.stdout.trim() == "true" => Ok(Some(true)),
8294        0 if output.stdout.trim() == "false" => Ok(Some(false)),
8295        0 => bail!(
8296            "git returned an invalid boolean for {key} in {}",
8297            cwd.display()
8298        ),
8299        1 => Ok(None),
8300        _ => bail!(
8301            "could not read Git configuration in {}: {}",
8302            cwd.display(),
8303            output.stderr.trim()
8304        ),
8305    }
8306}
8307
8308#[cfg(unix)]
8309fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
8310    use std::os::unix::fs::PermissionsExt;
8311    if metadata.permissions().mode() & 0o111 == 0 {
8312        "100644".to_string()
8313    } else {
8314        "100755".to_string()
8315    }
8316}
8317
8318#[cfg(not(unix))]
8319fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
8320    "100644".to_string()
8321}
8322
8323fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
8324    let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
8325    if !listed.is_empty() && !listed.ends_with(&[0]) {
8326        bail!(
8327            "git returned an unterminated tree listing for {}",
8328            cwd.display()
8329        );
8330    }
8331    let mut entries = Vec::new();
8332    for record in listed
8333        .split(|byte| *byte == 0)
8334        .filter(|record| !record.is_empty())
8335    {
8336        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
8337            bail!("git returned a malformed tree record for {}", cwd.display());
8338        };
8339        let fields = record[..tab]
8340            .split(|byte| *byte == b' ')
8341            .collect::<Vec<_>>();
8342        if fields.len() != 3 {
8343            bail!("git returned a malformed tree header for {}", cwd.display());
8344        }
8345        let mode = std::str::from_utf8(fields[0])
8346            .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
8347            .to_string();
8348        let oid = std::str::from_utf8(fields[2])
8349            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
8350            .to_string();
8351        entries.push(IndexEntry {
8352            path: safe_git_path(&record[tab + 1..], "tree")?,
8353            mode,
8354            oid,
8355        });
8356    }
8357    Ok(entries)
8358}
8359
8360fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
8361    Ok(tree_entries(cwd, "HEAD")?
8362        .into_iter()
8363        .filter(|entry| entry.mode == "160000")
8364        .map(|entry| (entry.path, entry.oid))
8365        .collect())
8366}
8367
8368fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
8369    let head = head_gitlinks(cwd)?;
8370    let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
8371        .into_iter()
8372        .map(|link| (link.path, link.oid))
8373        .collect();
8374    let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
8375    paths.extend(index.keys().cloned());
8376    Ok(paths
8377        .into_iter()
8378        .filter(|path| head.get(path) != index.get(path))
8379        .collect())
8380}
8381
8382fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
8383    let path = parent.join(relative);
8384    let metadata = match std::fs::symlink_metadata(&path) {
8385        Ok(metadata) => metadata,
8386        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8387        Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
8388    };
8389    if !metadata.is_dir() {
8390        bail!("the gitlink at {} is not a directory", path.display());
8391    }
8392    let canonical = std::fs::canonicalize(&path)
8393        .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
8394    if canonical != path {
8395        bail!(
8396            "the gitlink at {} resolves through a symlink",
8397            path.display()
8398        );
8399    }
8400    if !path.join(".git").exists() {
8401        let empty = std::fs::read_dir(&path)
8402            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
8403            .next()
8404            .is_none();
8405        if empty {
8406            return Ok(None);
8407        }
8408        bail!(
8409            "the uninitialized gitlink at {} contains local files",
8410            path.display()
8411        );
8412    }
8413    let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
8414    if inside.trim() != "true" {
8415        bail!("the gitlink at {} is not a worktree", path.display());
8416    }
8417    let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
8418    let top = std::fs::canonicalize(top.trim()).map_err(|e| {
8419        spar_err!(
8420            "could not resolve the gitlink top level at {}: {e}",
8421            path.display()
8422        )
8423    })?;
8424    if top != canonical {
8425        bail!(
8426            "the gitlink at {} belongs to a different worktree",
8427            path.display()
8428        );
8429    }
8430    Ok(Some(canonical))
8431}
8432
8433/// A Git entry the outer repository cannot account for.
8434///
8435/// A checkout Git itself reports as a nested repository is expected. Its
8436/// directory is recorded by identity in the untracked snapshot and keeps the
8437/// worktree from being released, so a reviewer that clones a reproduction into
8438/// ignored build output neither loses that clone nor fails the run over it.
8439/// The scan does not descend into one, because what is under it belongs to
8440/// that repository.
8441///
8442/// A Git entry Git does not report is the one worth stopping for. Running
8443/// `git init` inside a directory that already holds tracked files leaves
8444/// nothing in any listing while making every file written under it invisible
8445/// to the outer worktree, so SPAR can no longer prove what is there.
8446fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
8447    let root = std::fs::canonicalize(cwd)
8448        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8449    let mut allowed = BTreeSet::from([root.join(".git")]);
8450    let mut checkouts = BTreeSet::new();
8451    let mut repositories = vec![root.clone()];
8452    let mut visited = BTreeSet::new();
8453    while let Some(repository) = repositories.pop() {
8454        let canonical = std::fs::canonicalize(&repository)
8455            .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8456        if !visited.insert(canonical.clone()) {
8457            bail!("submodule recursion revisited {}", canonical.display());
8458        }
8459        for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
8460            let (path, nested) = untracked_record(&raw, "untracked")?;
8461            if nested {
8462                checkouts.insert(canonical.join(path));
8463            }
8464        }
8465        for link in gitlinks(&canonical)? {
8466            let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
8467                continue;
8468            };
8469            allowed.insert(submodule.join(".git"));
8470            repositories.push(submodule);
8471        }
8472    }
8473
8474    // A build deletes its own directories while this walks them. A directory
8475    // that is gone holds no Git entry, so skipping it answers the question
8476    // being asked; erroring instead ended runs whose worktrees were fine.
8477    let scan_root = root.clone();
8478    let mut directories = vec![root];
8479    while let Some(directory) = directories.pop() {
8480        let entries = match std::fs::read_dir(&directory) {
8481            Ok(entries) => entries,
8482            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8483            Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8484        };
8485        for entry in entries {
8486            let entry = match entry {
8487                Ok(entry) => entry,
8488                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8489                Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8490            };
8491            let path = entry.path();
8492            if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
8493                continue;
8494            }
8495            if entry.file_name() == OsStr::new(".git") {
8496                if !allowed.contains(&path) {
8497                    return Ok(Some(path));
8498                }
8499                continue;
8500            }
8501            let kind = match entry.file_type() {
8502                Ok(kind) => kind,
8503                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8504                Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
8505            };
8506            if kind.is_dir() && !checkouts.contains(&path) {
8507                directories.push(path);
8508            }
8509        }
8510    }
8511    Ok(None)
8512}
8513
8514pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
8515    if let Some(path) = unexpected_nested_git_entry(cwd)? {
8516        bail!(
8517            "the worktree contains a Git entry at {} that Git does not report as a nested \
8518             checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
8519             prove they are unchanged.",
8520            path.display()
8521        );
8522    }
8523    let root = std::fs::canonicalize(cwd)
8524        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8525    let mut repositories = BTreeMap::new();
8526    let mut visited = BTreeSet::new();
8527    collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
8528    Ok(GitState { repositories })
8529}
8530
8531fn collect_git_state(
8532    repository: &Path,
8533    prefix: &Path,
8534    visited: &mut BTreeSet<PathBuf>,
8535    repositories: &mut BTreeMap<PathBuf, RepositoryState>,
8536) -> Result<()> {
8537    let canonical = std::fs::canonicalize(repository)
8538        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8539    if !visited.insert(canonical.clone()) {
8540        bail!("submodule recursion revisited {}", canonical.display());
8541    }
8542    let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8543    let head = head.trim().to_string();
8544    if head.is_empty() {
8545        bail!("git returned an empty head for {}", repository.display());
8546    }
8547    let unsafe_index_flags = unsafe_index_flags(repository)?;
8548    let tracked = tracked_entries(repository)?;
8549    let gitlinks = gitlinks(repository)?;
8550    if repositories
8551        .insert(
8552            prefix.to_path_buf(),
8553            RepositoryState {
8554                head,
8555                unsafe_index_flags,
8556                tracked,
8557                gitlinks: gitlinks
8558                    .iter()
8559                    .map(|link| (link.path.clone(), link.oid.clone()))
8560                    .collect(),
8561            },
8562        )
8563        .is_some()
8564    {
8565        bail!("Git state contains duplicate repository path {:?}", prefix);
8566    }
8567
8568    for link in gitlinks {
8569        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
8570            continue;
8571        };
8572        collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
8573    }
8574    Ok(())
8575}
8576
8577fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
8578    let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
8579    if !listed.is_empty() && !listed.ends_with(&[0]) {
8580        bail!(
8581            "git returned an unterminated index-flag listing for {}",
8582            cwd.display()
8583        );
8584    }
8585    let mut unsafe_records = Vec::new();
8586    for record in listed
8587        .split(|byte| *byte == 0)
8588        .filter(|record| !record.is_empty())
8589    {
8590        if record.len() < 3 || record[1] != b' ' {
8591            bail!(
8592                "git returned a malformed index-flag record for {}",
8593                cwd.display()
8594            );
8595        }
8596        if record[0] != b'H' {
8597            unsafe_records.extend_from_slice(record);
8598            unsafe_records.push(0);
8599        }
8600    }
8601    Ok(unsafe_records)
8602}
8603
8604pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
8605    safe_git_state(cwd).map(|_| ())
8606}
8607
8608pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
8609    let state = git_state(cwd)?;
8610    if let Some((path, _repository)) = state
8611        .repositories
8612        .iter()
8613        .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
8614    {
8615        let label = if path.as_os_str().is_empty() {
8616            cwd.to_path_buf()
8617        } else {
8618            cwd.join(path)
8619        };
8620        bail!(
8621            "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
8622             SPAR cannot prove the working files are unchanged, so it was kept.",
8623            label.display()
8624        );
8625    }
8626    Ok(state)
8627}
8628
8629fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
8630    repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
8631}
8632
8633fn repository_has_recoverable_work_with(
8634    cwd: &Path,
8635    include_ignored: bool,
8636    disposable: Disposable,
8637) -> Result<bool> {
8638    if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
8639        return Ok(true);
8640    }
8641    let mut visited = BTreeSet::new();
8642    repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
8643}
8644
8645fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
8646    let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
8647    let git_dir = PathBuf::from(git_dir.trim());
8648    let git_dir = if git_dir.is_absolute() {
8649        git_dir
8650    } else {
8651        cwd.join(git_dir)
8652    };
8653    let git_dir = std::fs::canonicalize(&git_dir)
8654        .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
8655    match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
8656        Ok(_) => return Ok(true),
8657        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8658        Err(error) => {
8659            return Err(spar_err!(
8660                "could not inspect per-worktree configuration in {}: {error}",
8661                git_dir.display()
8662            ))
8663        }
8664    }
8665
8666    let orig_head = git_dir.join("ORIG_HEAD");
8667    match std::fs::symlink_metadata(&orig_head) {
8668        Ok(metadata) if metadata.is_file() => {
8669            let oid = std::fs::read_to_string(&orig_head)
8670                .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
8671            let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
8672                return Ok(true);
8673            };
8674            if !commit_has_shared_ref(cwd, &commit)? {
8675                return Ok(true);
8676            }
8677        }
8678        Ok(_) => return Ok(true),
8679        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8680        Err(error) => {
8681            return Err(spar_err!(
8682                "could not inspect {}: {error}",
8683                orig_head.display()
8684            ))
8685        }
8686    }
8687
8688    let edit_message = git_dir.join("COMMIT_EDITMSG");
8689    match std::fs::symlink_metadata(&edit_message) {
8690        Ok(metadata) if metadata.is_file() => {
8691            let draft = std::fs::read(&edit_message)
8692                .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
8693            if draft != head_commit_message(cwd)? {
8694                return Ok(true);
8695            }
8696        }
8697        Ok(_) => return Ok(true),
8698        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8699        Err(error) => {
8700            return Err(spar_err!(
8701                "could not inspect {}: {error}",
8702                edit_message.display()
8703            ))
8704        }
8705    }
8706
8707    if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
8708        return Ok(true);
8709    }
8710
8711    let local_refs = run_git_bytes(
8712        cwd,
8713        &[
8714            "for-each-ref",
8715            "--format=%(refname)",
8716            "refs/worktree",
8717            "refs/bisect",
8718            "refs/rewritten",
8719        ],
8720    )?;
8721    if !local_refs.is_empty() {
8722        return Ok(true);
8723    }
8724
8725    // `FETCH_HEAD` only names what a `git fetch` in this worktree brought
8726    // down. The objects live in the shared store and the next fetch rewrites
8727    // it, so an agent that fetched once must not keep the checkout forever.
8728    for entry in std::fs::read_dir(&git_dir)
8729        .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
8730    {
8731        let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
8732        let known = matches!(
8733            entry.file_name().to_str(),
8734            Some(
8735                "HEAD"
8736                    | "ORIG_HEAD"
8737                    | "COMMIT_EDITMSG"
8738                    | "FETCH_HEAD"
8739                    | "commondir"
8740                    | "gitdir"
8741                    | "index"
8742                    | "logs"
8743                    | "refs"
8744            )
8745        );
8746        if !known {
8747            return Ok(true);
8748        }
8749    }
8750
8751    let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8752    if !commit_has_shared_ref(cwd, head.trim())? {
8753        return Ok(true);
8754    }
8755    Ok(false)
8756}
8757
8758fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
8759    let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
8760    let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
8761        bail!(
8762            "git returned a commit without a message separator in {}",
8763            cwd.display()
8764        );
8765    };
8766    Ok(commit[split + 2..].to_vec())
8767}
8768
8769fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
8770    let metadata = match std::fs::symlink_metadata(logs) {
8771        Ok(metadata) => metadata,
8772        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
8773        Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
8774    };
8775    if !metadata.is_dir() {
8776        return Ok(true);
8777    }
8778    let mut files = Vec::new();
8779    let mut directories = vec![logs.to_path_buf()];
8780    while let Some(directory) = directories.pop() {
8781        for entry in std::fs::read_dir(&directory)
8782            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
8783        {
8784            let entry =
8785                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
8786            let path = entry.path();
8787            let kind = entry
8788                .file_type()
8789                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
8790            if kind.is_dir() {
8791                directories.push(path);
8792            } else if kind.is_file() {
8793                files.push(path);
8794            } else {
8795                return Ok(true);
8796            }
8797        }
8798    }
8799
8800    let mut commits = BTreeSet::new();
8801    for path in files {
8802        if !collect_reflog_commits(cwd, &path, &mut commits)? {
8803            return Ok(true);
8804        }
8805    }
8806    for commit in commits {
8807        if !commit_has_shared_ref(cwd, &commit)? {
8808            return Ok(true);
8809        }
8810    }
8811    Ok(false)
8812}
8813
8814/// Every commit named by one ref's common reflog must survive deletion of that
8815/// ref. Ancestors of a durable current tip survive with the tip; divergent
8816/// entries need another shared ref of their own.
8817fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
8818    let common = common_git_dir(cwd)?;
8819    let reflog = common.join("logs").join(refname);
8820    let metadata = match std::fs::symlink_metadata(&reflog) {
8821        Ok(metadata) => metadata,
8822        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
8823        Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
8824    };
8825    if !metadata.is_file() {
8826        return Ok(false);
8827    }
8828    let mut commits = BTreeSet::new();
8829    if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
8830        return Ok(false);
8831    }
8832    for commit in commits {
8833        if is_ancestor(cwd, &commit, durable_tip)?
8834            || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
8835        {
8836            continue;
8837        }
8838        return Ok(false);
8839    }
8840    Ok(true)
8841}
8842
8843fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
8844    let file = std::fs::File::open(path)
8845        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8846    for line in std::io::BufReader::new(file).lines() {
8847        let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8848        let mut fields = line.splitn(3, ' ');
8849        let Some(old) = fields.next() else {
8850            return Ok(false);
8851        };
8852        let Some(new) = fields.next() else {
8853            return Ok(false);
8854        };
8855        if fields.next().is_none() {
8856            return Ok(false);
8857        }
8858        for oid in [old, new] {
8859            if oid.bytes().all(|byte| byte == b'0') {
8860                continue;
8861            }
8862            let Some(commit) = resolve_optional_commit(cwd, oid)? else {
8863                return Ok(false);
8864            };
8865            commits.insert(commit);
8866        }
8867    }
8868    Ok(true)
8869}
8870
8871fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
8872    let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
8873    let path = PathBuf::from(raw.trim());
8874    let path = if path.is_absolute() {
8875        path
8876    } else {
8877        cwd.join(path)
8878    };
8879    std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
8880}
8881
8882fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
8883    let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
8884    let output = proc::exec(
8885        &argv,
8886        &ExecOpts::new()
8887            .cwd(cwd)
8888            .timeout_secs(30)
8889            .check(false)
8890            .stop_descendants(true),
8891    )?;
8892    match output.code {
8893        0 => Ok(true),
8894        1 => Ok(false),
8895        _ => bail!("{}", proc::failure_message(&argv, &output)),
8896    }
8897}
8898
8899fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
8900    let commit = format!("{oid}^{{commit}}");
8901    let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
8902    let output = proc::exec(
8903        &argv,
8904        &ExecOpts::new()
8905            .cwd(cwd)
8906            .timeout_secs(30)
8907            .check(false)
8908            .stop_descendants(true),
8909    )?;
8910    if output.code != 0 {
8911        return Ok(None);
8912    }
8913    let oid = output.stdout.trim();
8914    if oid.is_empty() {
8915        return Ok(None);
8916    }
8917    Ok(Some(oid.to_string()))
8918}
8919
8920fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
8921    commit_has_shared_ref_except(cwd, oid, None)
8922}
8923
8924fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
8925    let contains = format!("--contains={oid}");
8926    let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
8927    Ok(shared.split(|byte| *byte == b'\n').any(|record| {
8928        !record.is_empty()
8929            && !record.starts_with(b"refs/worktree/")
8930            && !record.starts_with(b"refs/bisect/")
8931            && !record.starts_with(b"refs/rewritten/")
8932            && exclude.is_none_or(|excluded| record != excluded.as_bytes())
8933    }))
8934}
8935
8936/// Whether removing the worktree would take away an untracked file somebody
8937/// might want back.
8938///
8939/// Ordinary untracked files always count. So does an ignored file outside the
8940/// known build and cache directories, because an ignored path is only a path
8941/// Git was told not to track, which is where a local `.env` lives as readily as
8942/// compiler output.
8943///
8944/// Recognized build and cache output does not. A managed commit already leaves
8945/// it out rather than treating it as work, and the command that wrote it writes
8946/// it again. Counting it kept every worktree whose tests or build had run,
8947/// which is nearly all of them, so a merged pull request still left its
8948/// checkout behind. A repository nested in that output is somebody else's
8949/// history and counts whatever it sits under.
8950fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
8951    let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
8952    for raw in &ordinary {
8953        let (path, _) = untracked_record(raw, "untracked")?;
8954        if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
8955            continue;
8956        }
8957        return Ok(true);
8958    }
8959    let listed = untracked_listing(
8960        cwd,
8961        &[
8962            "ls-files",
8963            "--others",
8964            "--ignored",
8965            "--exclude-standard",
8966            "-z",
8967        ],
8968    )?;
8969    for raw in listed {
8970        let (path, nested) = untracked_record(&raw, "ignored")?;
8971        // A repository cloned into the output keeps the checkout whatever the
8972        // mode: its objects exist nowhere else, which is the one thing a
8973        // rebuild from origin cannot replace.
8974        if nested {
8975            return Ok(true);
8976        }
8977        if disposable == Disposable::BuildOutput
8978            && !is_generated_artifact(&path)
8979            && !is_desktop_metadata(&path)
8980        {
8981            return Ok(true);
8982        }
8983    }
8984    Ok(false)
8985}
8986
8987fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8988    let listed = run_git_bytes(cwd, args)?;
8989    if !listed.is_empty() && !listed.ends_with(&[0]) {
8990        bail!(
8991            "git returned an unterminated untracked-file list for {}",
8992            cwd.display()
8993        );
8994    }
8995    Ok(listed
8996        .split(|byte| *byte == 0)
8997        .filter(|raw| !raw.is_empty())
8998        .map(|raw| raw.to_vec())
8999        .collect())
9000}
9001
9002fn repository_has_recoverable_work_inner(
9003    cwd: &Path,
9004    include_ignored: bool,
9005    disposable: Disposable,
9006    visited: &mut BTreeSet<PathBuf>,
9007) -> Result<bool> {
9008    let canonical = std::fs::canonicalize(cwd)
9009        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
9010    if !visited.insert(canonical.clone()) {
9011        bail!("submodule recursion revisited {}", canonical.display());
9012    }
9013    if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
9014        return Ok(true);
9015    }
9016    if !unsafe_index_flags(cwd)?.is_empty() {
9017        return Ok(true);
9018    }
9019    if attributes_may_be_modified(cwd)? {
9020        return Ok(true);
9021    }
9022    if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
9023        return Ok(true);
9024    }
9025    if include_ignored {
9026        let index = index_entries(cwd)?
9027            .into_iter()
9028            .map(|entry| (entry.path, (entry.mode, entry.oid)))
9029            .collect::<BTreeMap<_, _>>();
9030        let head = tree_entries(cwd, "HEAD")?
9031            .into_iter()
9032            .map(|entry| (entry.path, (entry.mode, entry.oid)))
9033            .collect::<BTreeMap<_, _>>();
9034        if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
9035            return Ok(true);
9036        }
9037        let tracked = tracked_entries(cwd)?;
9038        let effective = check_attributes(cwd, tracked.keys().cloned())?;
9039        let config = CheckoutConfig::read(cwd)?;
9040        for (path, entry) in tracked {
9041            let Some(worktree) = entry.worktree else {
9042                return Ok(true);
9043            };
9044            let attributes = effective.get(&path).ok_or_else(|| {
9045                spar_err!("git omitted attributes for {}", cwd.join(&path).display())
9046            })?;
9047            if path_has_ambiguous_transform(&config, attributes)? {
9048                return Ok(true);
9049            }
9050            let symlink_file = entry.index_mode == "120000"
9051                && worktree.mode == "100644"
9052                && worktree.raw_oid == entry.index_oid
9053                && config.symlinks == Some(false);
9054            if worktree.mode != entry.index_mode && !symlink_file {
9055                return Ok(true);
9056            }
9057            if entry.index_mode == "120000" {
9058                if worktree.raw_oid != entry.index_oid {
9059                    return Ok(true);
9060                }
9061                continue;
9062            }
9063            #[cfg(unix)]
9064            {
9065                let expected = if entry.index_mode == "100755" {
9066                    0o755
9067                } else {
9068                    0o644
9069                };
9070                if worktree.permissions != expected {
9071                    return Ok(true);
9072                }
9073            }
9074            if allows_expected_crlf(&config, attributes)? {
9075                let (normalized, every_lf_was_crlf) =
9076                    normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
9077                if !every_lf_was_crlf || normalized != entry.index_oid {
9078                    return Ok(true);
9079                }
9080            } else if worktree.raw_oid != entry.index_oid {
9081                return Ok(true);
9082            }
9083        }
9084    } else {
9085        let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
9086        if !run_git_bytes(cwd, &args)?.is_empty() {
9087            return Ok(true);
9088        }
9089    }
9090    for link in gitlinks(cwd)? {
9091        let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
9092            continue;
9093        };
9094        // Git stores a linked worktree's initialized submodule objects under
9095        // that worktree's administrative directory. Ordinary removal cannot
9096        // prove a local submodule commit exists anywhere else, even when both
9097        // working trees look clean.
9098        if include_ignored {
9099            return Ok(true);
9100        }
9101        let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
9102        if head.trim() != link.oid {
9103            return Ok(true);
9104        }
9105        if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
9106        {
9107            return Ok(true);
9108        }
9109    }
9110    Ok(false)
9111}
9112
9113pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
9114    repository_has_recoverable_work(cwd, false)
9115}
9116
9117fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
9118    let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
9119    Ok(!run_git_bytes(cwd, &args)?.is_empty())
9120}
9121
9122#[cfg(unix)]
9123fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
9124    use std::os::unix::ffi::OsStringExt;
9125    Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
9126}
9127
9128#[cfg(not(unix))]
9129fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
9130    String::from_utf8(raw.to_vec())
9131        .map(PathBuf::from)
9132        .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
9133}
9134
9135/// Fingerprint the directory of a nested repository without reading inside it.
9136///
9137/// The files under it are that repository's, not this one's. They are recorded
9138/// against its own baseline whenever SPAR works there, and a run of its own may
9139/// legitimately add or remove entries while this call is in flight, so the
9140/// volatile directory fields stay out of the fingerprint. Identity and type
9141/// remain, which is what makes deleting the checkout, or replacing it with a
9142/// file, observable from the outer worktree.
9143/// What a path Git listed looks like once it is no longer there.
9144///
9145/// A reviewer building the project deletes and rewrites its output constantly,
9146/// so a path can go between the listing that named it and the stat that
9147/// measures it. Treating that as an unreadable worktree ended runs that had
9148/// nothing wrong with them. The record stays in the snapshot rather than being
9149/// dropped, so a path that vanishes cannot pass for one that was never there.
9150fn vanished_untracked_file() -> UntrackedFile {
9151    UntrackedFile {
9152        kind: 0,
9153        len: 0,
9154        modified: None,
9155        created: None,
9156        readonly: false,
9157        symlink_target: None,
9158        #[cfg(unix)]
9159        device: 0,
9160        #[cfg(unix)]
9161        inode: 0,
9162        #[cfg(unix)]
9163        mode: 0,
9164        #[cfg(unix)]
9165        change_seconds: 0,
9166        #[cfg(unix)]
9167        change_nanoseconds: 0,
9168    }
9169}
9170
9171fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
9172    let metadata = match std::fs::symlink_metadata(path) {
9173        Ok(metadata) => metadata,
9174        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
9175        Err(e) => {
9176            return Err(spar_err!(
9177                "could not inspect the nested repository at {}: {e}",
9178                path.display()
9179            ))
9180        }
9181    };
9182    if !metadata.is_dir() {
9183        bail!(
9184            "git reported {} as a nested repository, but it is not a directory",
9185            path.display()
9186        );
9187    }
9188    if std::fs::symlink_metadata(path.join(".git")).is_err() {
9189        bail!(
9190            "git reported {} as a nested repository, but it has no Git entry",
9191            path.display()
9192        );
9193    }
9194    #[cfg(unix)]
9195    {
9196        use std::os::unix::fs::MetadataExt;
9197        Ok(UntrackedFile {
9198            kind: 3,
9199            len: 0,
9200            modified: None,
9201            created: metadata.created().ok(),
9202            readonly: metadata.permissions().readonly(),
9203            symlink_target: None,
9204            device: metadata.dev(),
9205            inode: metadata.ino(),
9206            mode: metadata.mode(),
9207            change_seconds: 0,
9208            change_nanoseconds: 0,
9209        })
9210    }
9211    #[cfg(not(unix))]
9212    {
9213        Ok(UntrackedFile {
9214            kind: 3,
9215            len: 0,
9216            modified: None,
9217            created: metadata.created().ok(),
9218            readonly: metadata.permissions().readonly(),
9219            symlink_target: None,
9220        })
9221    }
9222}
9223
9224fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
9225    let metadata = match std::fs::symlink_metadata(path) {
9226        Ok(metadata) => metadata,
9227        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
9228        Err(e) => {
9229            return Err(spar_err!(
9230                "could not inspect untracked file {}: {e}",
9231                path.display()
9232            ))
9233        }
9234    };
9235    let kind = if metadata.file_type().is_symlink() {
9236        2
9237    } else if metadata.is_file() {
9238        1
9239    } else {
9240        bail!(
9241            "untracked path {} is not a regular file or symlink",
9242            path.display()
9243        );
9244    };
9245    let symlink_target = if kind == 2 {
9246        match std::fs::read_link(path) {
9247            Ok(target) => Some(os_str_bytes(target.as_os_str())?),
9248            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
9249                return Ok(vanished_untracked_file())
9250            }
9251            Err(e) => {
9252                return Err(spar_err!(
9253                    "could not read untracked symlink {}: {e}",
9254                    path.display()
9255                ))
9256            }
9257        }
9258    } else {
9259        None
9260    };
9261    #[cfg(unix)]
9262    {
9263        use std::os::unix::fs::MetadataExt;
9264        Ok(UntrackedFile {
9265            kind,
9266            len: metadata.len(),
9267            modified: metadata.modified().ok(),
9268            created: metadata.created().ok(),
9269            readonly: metadata.permissions().readonly(),
9270            symlink_target,
9271            device: metadata.dev(),
9272            inode: metadata.ino(),
9273            mode: metadata.mode(),
9274            change_seconds: metadata.ctime(),
9275            change_nanoseconds: metadata.ctime_nsec(),
9276        })
9277    }
9278    #[cfg(not(unix))]
9279    {
9280        Ok(UntrackedFile {
9281            kind,
9282            len: metadata.len(),
9283            modified: metadata.modified().ok(),
9284            created: metadata.created().ok(),
9285            readonly: metadata.permissions().readonly(),
9286            symlink_target,
9287        })
9288    }
9289}
9290
9291#[cfg(unix)]
9292fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
9293    use std::os::unix::ffi::OsStrExt;
9294    Ok(value.as_bytes().to_vec())
9295}
9296
9297#[cfg(not(unix))]
9298fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
9299    value
9300        .to_str()
9301        .map(|value| value.as_bytes().to_vec())
9302        .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
9303}
9304
9305#[cfg(unix)]
9306fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
9307    use std::os::unix::fs::MetadataExt;
9308    right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
9309}
9310
9311#[cfg(not(unix))]
9312fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
9313    right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
9314}
9315
9316#[derive(Debug, Clone, serde::Serialize, Deserialize)]
9317pub struct BranchRecord {
9318    pub kind: String,
9319    pub number: i64,
9320}
9321
9322/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
9323/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
9324pub fn review_ref(number: i64) -> String {
9325    format!("refs/spar/pr-{number}")
9326}
9327
9328pub fn is_finished(state: &str) -> bool {
9329    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
9330}
9331
9332/// Write text through a temporary file and rename, so a kill cannot leave a
9333/// truncated file behind.
9334///
9335/// The follow-up queue is the one file spar rewrites in place rather than
9336/// appends to, and a truncated queue is lost work: what it held was never
9337/// written anywhere else.
9338pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
9339    if let Some(parent) = path.parent() {
9340        std::fs::create_dir_all(parent)
9341            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
9342    }
9343    // The extension defaults to `json` so `clear_state`, which removes a
9344    // leftover `pr-N.json.tmp` by name, keeps finding the one this wrote.
9345    let tmp = path.with_extension(format!(
9346        "{}.tmp",
9347        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
9348    ));
9349    std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
9350    std::fs::rename(&tmp, path)
9351        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
9352    Ok(())
9353}
9354
9355/// Whether a follow-up file already has an entry under this exact title.
9356///
9357/// Whole heading lines, case insensitively. A substring check answered yes
9358/// for "Retry loop" against "## Retry loop never terminates", which is a
9359/// different defect, and the new one was dropped with a line saying it was
9360/// already noted.
9361pub fn has_heading(text: &str, title: &str) -> bool {
9362    let wanted = title.trim().to_lowercase();
9363    if wanted.is_empty() {
9364        return false;
9365    }
9366    text.lines()
9367        .filter_map(|line| line.trim().strip_prefix("## "))
9368        .any(|heading| heading.trim().to_lowercase() == wanted)
9369}
9370
9371/// Write JSON through a temporary file and rename, so a kill cannot leave a
9372/// truncated state file behind.
9373pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
9374    write_text_atomic(path, &serde_json::to_string_pretty(value)?)
9375}
9376
9377/// One row of `gh pr list --json number,url,title,baseRefName,closingIssuesReferences`.
9378#[derive(Deserialize)]
9379#[serde(rename_all = "camelCase")]
9380pub struct LinkedPrRow {
9381    number: i64,
9382    #[serde(default)]
9383    url: String,
9384    #[serde(default)]
9385    title: String,
9386    #[serde(default)]
9387    closing_issues_references: Vec<IssueRef>,
9388}
9389
9390/// The listed pull requests, or `None` when the payload could not be read.
9391///
9392/// Separated from the gh call so the real payload shape can be tested, and kept
9393/// distinct from an empty list, because silently failing to parse would look
9394/// exactly like "no pull request exists", which is the answer that makes spar
9395/// implement over the top of somebody's work.
9396pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
9397    serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
9398}
9399
9400/// Among the listed pull requests, the first that would close `issue`.
9401pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
9402    rows.iter()
9403        .find(|row| {
9404            row.closing_issues_references
9405                .iter()
9406                .any(|linked| linked.number == issue)
9407        })
9408        .map(|row| PrRef {
9409            number: row.number,
9410            url: row.url.clone(),
9411            title: row.title.clone(),
9412        })
9413}
9414
9415/// Both halves together, for the tests that hold the real payload.
9416pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
9417    pick_linked_pr(&linked_pr_rows(json)?, issue)
9418}
9419
9420/// Flatten whatever `gh api --paginate` printed into a list of comments.
9421///
9422/// Current gh merges array pages into one array. Older builds concatenated one
9423/// document per page. A streaming parser reads either, and unlike splitting the
9424/// text on a bracket pair it cannot be fooled by a comment body that happens to
9425/// contain one, which would otherwise make a resume silently start over.
9426pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
9427    if text.trim().is_empty() {
9428        return Err(spar_err!("GitHub returned no comment data"));
9429    }
9430    let mut out = Vec::new();
9431    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
9432        match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
9433            Value::Array(items) => out.extend(items),
9434            _ => return Err(spar_err!("unexpected non-array comment page")),
9435        }
9436    }
9437    Ok(out)
9438}
9439
9440pub fn parse_comment_pages(text: &str) -> Vec<Value> {
9441    let mut out = Vec::new();
9442    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
9443        match value {
9444            Ok(Value::Array(items)) => out.extend(items),
9445            Ok(other) => out.push(other),
9446            Err(_) => break,
9447        }
9448    }
9449    out
9450}
9451
9452/// Whether a comment is a state block spar itself wrote.
9453///
9454/// The body has to open with the marker, so a person quoting it in a sentence
9455/// is not one; it has to parse, so a half-quoted block is not one; and it has
9456/// to be the viewer's own comment, the way `comments.rs` already tells spar's
9457/// writes apart. Anything less and `write_pr_state` patches somebody's comment
9458/// with JSON, and `spar clean --pr-state` later deletes it.
9459pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
9460    body.trim_start().starts_with(STATE_MARKER)
9461        && !viewer.trim().is_empty()
9462        && author.trim().eq_ignore_ascii_case(viewer.trim())
9463        && parse_state_comment(body).is_some()
9464}
9465
9466/// Extract the payload from a state comment. The marker is followed by JSON and
9467/// terminated with `-->`.
9468pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
9469    let marker = body.find(STATE_MARKER)?;
9470    let start = body[marker..].find('{')? + marker;
9471    let end = body.rfind('}')?;
9472    if end <= start {
9473        return None;
9474    }
9475    match serde_json::from_str(&body[start..=end]) {
9476        Ok(state) => Some(state),
9477        Err(_) => {
9478            logdim!("found a spar state comment but could not parse it");
9479            None
9480        }
9481    }
9482}
9483
9484fn choose_state_for_head(
9485    candidates: Vec<PersistedState>,
9486    actual_head: &str,
9487) -> Option<PersistedState> {
9488    let matching: Vec<PersistedState> = candidates
9489        .iter()
9490        .filter(|state| state.pr_head == actual_head)
9491        .cloned()
9492        .collect();
9493    if !matching.is_empty() {
9494        return newest_state(matching);
9495    }
9496    newest_state(candidates)
9497}
9498
9499fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
9500    candidates.into_iter().reduce(|best, candidate| {
9501        if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
9502            candidate
9503        } else {
9504            // The local candidate is supplied first. Keeping the first exact
9505            // tie recovers correctly from a local write followed by a failed
9506            // pull request state update, including legacy states with no
9507            // checkpoint field.
9508            best
9509        }
9510    })
9511}
9512
9513/// Where this binary lives, so `git filter-branch` can call back into it.
9514///
9515/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
9516/// tests, whose `current_exe` is the test harness rather than spar, and for
9517/// anyone who ships spar behind a wrapper script.
9518pub fn self_binary() -> Result<PathBuf> {
9519    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
9520        let path = PathBuf::from(path);
9521        if proc::is_executable(&path) {
9522            return Ok(path);
9523        }
9524        bail!(
9525            "SPAR_SELF_BIN is set to {}, which is not executable",
9526            path.display()
9527        );
9528    }
9529    std::env::current_exe()
9530        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
9531}
9532
9533fn bool_env(value: bool) -> &'static str {
9534    if value {
9535        "1"
9536    } else {
9537        "0"
9538    }
9539}
9540
9541/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
9542/// shell command, and an install path with a space in it is not exotic.
9543pub fn sh_quote(text: &str) -> String {
9544    format!("'{}'", text.replace('\'', r"'\''"))
9545}
9546
9547/// Style rules for the `scrub-filter` subcommand, which runs in a child process
9548/// spawned by git and so cannot see the parent's config.
9549pub fn style_from_env() -> Style {
9550    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
9551    Style {
9552        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
9553        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
9554        ..Style::permissive()
9555    }
9556}
9557
9558#[cfg(test)]
9559mod tests {
9560    use super::*;
9561    use crate::config::StateStore;
9562    use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
9563    use std::process::Command;
9564
9565    fn repo_for_titles() -> Repo {
9566        Repo {
9567            root: PathBuf::from("/nonexistent"),
9568            style: Style::default(),
9569            branch_prefix: String::new(),
9570            state_store: StateStore::Local,
9571            followups: crate::config::Followups::Issues,
9572            drafts: Drafts::Never,
9573            fork_when_denied: true,
9574            setup: CheckCommand::default(),
9575            isolate_reads: true,
9576            keep_worktrees: false,
9577            fork: OnceLock::new(),
9578            viewer: OnceLock::new(),
9579            checkpoints: Mutex::new(BTreeMap::new()),
9580            writes: WriteStats::default(),
9581        }
9582    }
9583
9584    /// The sweep deletes on what this says, so a name it does not recognise
9585    /// is no read checkout at all.
9586    #[test]
9587    fn only_a_read_checkout_name_names_its_owner() {
9588        assert_eq!(Some(4242), read_checkout_owner("read-4242-0"));
9589        assert_eq!(None, read_checkout_owner("read-4242"));
9590        assert_eq!(None, read_checkout_owner("read-me-0"));
9591        assert_eq!(None, read_checkout_owner("read-4242-x"));
9592        assert_eq!(None, read_checkout_owner("review-4242"));
9593    }
9594
9595    #[test]
9596    fn write_results_accumulate_for_the_run() {
9597        let repo = repo_for_titles();
9598
9599        let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
9600        let _: std::result::Result<(), ()> = repo.record_write(Err(()));
9601
9602        assert_eq!(
9603            WriteSummary {
9604                attempted: 2,
9605                failed: 1,
9606            },
9607            repo.write_summary()
9608        );
9609    }
9610
9611    #[test]
9612    fn only_failed_write_preflights_join_the_summary() {
9613        let repo = repo_for_titles();
9614
9615        let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
9616        let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
9617
9618        assert_eq!(
9619            WriteSummary {
9620                attempted: 1,
9621                failed: 1,
9622            },
9623            repo.write_summary()
9624        );
9625    }
9626
9627    #[test]
9628    fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
9629        let repo = repo_for_titles();
9630
9631        assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
9632        assert_eq!(
9633            WriteSummary {
9634                attempted: 1,
9635                failed: 1,
9636            },
9637            repo.write_summary()
9638        );
9639    }
9640
9641    #[test]
9642    fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
9643        let mut repo = repo_for_titles();
9644        repo.followups = Followups::Local;
9645
9646        assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
9647        assert_eq!(WriteSummary::default(), repo.write_summary());
9648    }
9649
9650    #[test]
9651    fn a_failed_remote_state_read_stops_before_state_mutation() {
9652        let root = std::env::temp_dir().join(format!(
9653            "spar-state-preflight-{}-{}",
9654            std::process::id(),
9655            std::time::SystemTime::now()
9656                .duration_since(std::time::UNIX_EPOCH)
9657                .unwrap()
9658                .as_nanos()
9659        ));
9660        std::fs::create_dir_all(&root).unwrap();
9661        let _fixture = ReviewFixture { root: root.clone() };
9662        let mut repo = repo_for_titles();
9663        repo.root = root;
9664        repo.state_store = StateStore::Both;
9665        let state = PersistedState {
9666            version: 1,
9667            checkpoint: 4,
9668            round: 2,
9669            next_actor: "a".into(),
9670            status: Status::Pending,
9671            pr_head: "abc123".into(),
9672            ledger: Ledger::new(),
9673            filed: Vec::new(),
9674            open_findings: Vec::new(),
9675            disputes: Vec::new(),
9676            noted: Vec::new(),
9677        };
9678
9679        let error = repo
9680            .write_state_after_remote_read(
9681                7,
9682                &state,
9683                Err(crate::error::SparError::new("state comments unavailable")),
9684            )
9685            .unwrap_err();
9686
9687        assert!(error.to_string().contains("state comments unavailable"));
9688        assert!(!repo.state_path(7).exists());
9689        assert_eq!(0, repo.remembered_checkpoint(7));
9690        assert_eq!(
9691            WriteSummary {
9692                attempted: 1,
9693                failed: 1,
9694            },
9695            repo.write_summary()
9696        );
9697    }
9698
9699    #[test]
9700    fn only_known_build_and_cache_directories_are_generated_artifacts() {
9701        assert!(is_generated_artifact(Path::new("target/debug/artifact")));
9702        assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
9703        assert!(is_generated_artifact(Path::new(
9704            "package/node_modules/dependency/file.js"
9705        )));
9706        assert!(!is_generated_artifact(Path::new(
9707            "distribution/required-package.js"
9708        )));
9709        assert!(!is_generated_artifact(Path::new(
9710            "generated/required-fixture.txt"
9711        )));
9712        assert!(!is_generated_artifact(Path::new("local.env")));
9713    }
9714
9715    #[test]
9716    fn only_finder_state_files_are_desktop_metadata() {
9717        assert!(is_desktop_metadata(Path::new(".DS_Store")));
9718        assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
9719        assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
9720        assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
9721    }
9722
9723    struct ReviewFixture {
9724        root: PathBuf,
9725    }
9726
9727    impl Drop for ReviewFixture {
9728        fn drop(&mut self) {
9729            let _ = std::fs::remove_dir_all(&self.root);
9730        }
9731    }
9732
9733    fn test_git(cwd: &Path, args: &[&str]) -> String {
9734        let output = Command::new("git")
9735            .args(args)
9736            .current_dir(cwd)
9737            .output()
9738            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
9739        assert!(
9740            output.status.success(),
9741            "git {args:?} failed: {}",
9742            String::from_utf8_lossy(&output.stderr)
9743        );
9744        String::from_utf8_lossy(&output.stdout).into_owned()
9745    }
9746
9747    /// The diff a line comment is checked against is parsed from git's output,
9748    /// so a user setting that changes that output must not change the answer,
9749    /// and a text converter the branch could have changed must never run.
9750    #[test]
9751    fn the_lines_a_comment_can_go_on_ignore_how_the_user_has_git_print_a_diff() {
9752        let root =
9753            std::env::temp_dir().join(format!("spar-repo-test-hunks-{}", std::process::id()));
9754        let _cleanup = ReviewFixture { root: root.clone() };
9755        let origin = root.join("origin.git");
9756        let work = root.join("work");
9757        let marker = root.join("textconv-ran");
9758        std::fs::create_dir_all(&origin).unwrap();
9759        std::fs::create_dir_all(&work).unwrap();
9760        test_git(&origin, &["init", "--bare", "-b", "main"]);
9761        test_git(&work, &["init", "-b", "main"]);
9762        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9763        test_git(&work, &["config", "user.name", "spar test"]);
9764        test_git(&work, &["config", "commit.gpgsign", "false"]);
9765        test_git(&work, &["config", "diff.noprefix", "true"]);
9766        test_git(&work, &["config", "diff.context", "0"]);
9767        test_git(&work, &["config", "diff.interHunkContext", "10"]);
9768        test_git(&work, &["config", "diff.algorithm", "patience"]);
9769        test_git(&work, &["config", "color.diff", "always"]);
9770        test_git(
9771            &work,
9772            &[
9773                "config",
9774                "diff.conv.textconv",
9775                &format!("touch {}; cat", marker.display()),
9776            ],
9777        );
9778        std::fs::write(work.join(".git/info/attributes"), "*.txt diff=conv\n").unwrap();
9779        let lines: String = (1..=20).map(|n| format!("line {n}\n")).collect();
9780        std::fs::write(work.join("src.txt"), &lines).unwrap();
9781        test_git(&work, &["add", "."]);
9782        test_git(&work, &["commit", "-m", "seed"]);
9783        test_git(
9784            &work,
9785            &["remote", "add", "origin", origin.to_str().unwrap()],
9786        );
9787        test_git(&work, &["push", "-u", "origin", "main"]);
9788        test_git(&work, &["checkout", "-b", "feature"]);
9789        std::fs::write(
9790            work.join("src.txt"),
9791            lines
9792                .replace("line 5\n", "changed 5\n")
9793                .replace("line 15\n", "changed 15\n"),
9794        )
9795        .unwrap();
9796        std::fs::write(work.join("new.txt"), "a\nb\n").unwrap();
9797        std::fs::write(work.join("with space.txt"), "a\n").unwrap();
9798        test_git(&work, &["add", "."]);
9799        test_git(&work, &["commit", "-m", "change"]);
9800        let cfg = crate::config::parse(
9801            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9802        )
9803        .unwrap();
9804        let repo = Repo::open(&work, &cfg).unwrap();
9805        assert_eq!(
9806            Hunks::from([
9807                ("new.txt".to_string(), vec![(1, 2)]),
9808                ("src.txt".to_string(), vec![(2, 8), (12, 18)]),
9809                ("with space.txt".to_string(), vec![(1, 1)]),
9810            ]),
9811            repo.diff_hunks(&work, "main")
9812        );
9813        assert!(!marker.exists(), "the text converter ran");
9814        // The converter is real: a plain diff with the same settings runs it.
9815        test_git(&work, &["diff", "origin/main...HEAD"]);
9816        assert!(marker.exists(), "the fixture's converter never runs at all");
9817    }
9818
9819    fn review_fixture(
9820        tag: &str,
9821        number: i64,
9822    ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
9823        use std::sync::atomic::{AtomicU32, Ordering};
9824        static NEXT: AtomicU32 = AtomicU32::new(0);
9825        let id = NEXT.fetch_add(1, Ordering::Relaxed);
9826        let root =
9827            std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
9828        let origin = root.join("origin.git");
9829        let work = root.join("work");
9830        std::fs::create_dir_all(&origin).unwrap();
9831        std::fs::create_dir_all(&work).unwrap();
9832        test_git(&origin, &["init", "--bare", "-b", "main"]);
9833        test_git(&work, &["init", "-b", "main"]);
9834        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9835        test_git(&work, &["config", "user.name", "spar test"]);
9836        test_git(&work, &["config", "commit.gpgsign", "false"]);
9837        test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
9838        test_git(&work, &["config", "filter.drop.smudge", "cat"]);
9839        std::fs::write(work.join("README.md"), "seed\n").unwrap();
9840        std::fs::write(work.join("data.txt"), "old\n").unwrap();
9841        std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
9842        std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
9843        test_git(&work, &["add", "."]);
9844        test_git(&work, &["commit", "-m", "seed"]);
9845        test_git(
9846            &work,
9847            &["remote", "add", "origin", origin.to_str().unwrap()],
9848        );
9849        test_git(&work, &["push", "-u", "origin", "main"]);
9850        test_git(
9851            &work,
9852            &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
9853        );
9854        let cfg = crate::config::parse(
9855            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9856        )
9857        .unwrap();
9858        let repo = Repo::open(&work, &cfg).unwrap();
9859        let path = repo.worktree_for_pr_head(number).unwrap();
9860        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9861        (ReviewFixture { root }, repo, path, checkpoint)
9862    }
9863
9864    #[test]
9865    fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
9866        let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
9867
9868        repo.release_review_worktree_checked(901, &checkpoint)
9869            .unwrap();
9870
9871        assert!(!path.exists());
9872    }
9873
9874    #[test]
9875    fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
9876        let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
9877        let work = fixture.root.join("work");
9878        let seed = test_git(&work, &["rev-parse", "HEAD"]);
9879        let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
9880        // Heads only the pull request holds, the second rewritten rather than
9881        // built on the first, as a rebase and force push leave them.
9882        let publish = |message: &str| {
9883            let oid = test_git(
9884                &work,
9885                &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
9886            );
9887            let oid = oid.trim().to_string();
9888            test_git(
9889                &work,
9890                &[
9891                    "push",
9892                    "--force",
9893                    "origin",
9894                    &format!("{oid}:refs/pull/923/head"),
9895                ],
9896            );
9897            oid
9898        };
9899        let reviewed = publish("reviewed head");
9900        repo.worktree_for_pr_head(923).unwrap();
9901        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
9902
9903        let rewritten = publish("rewritten head");
9904        repo.worktree_for_pr_head(923).unwrap();
9905
9906        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
9907    }
9908
9909    #[test]
9910    fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
9911        let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
9912        let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
9913        std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
9914        test_git(&path, &["add", "recovery.txt"]);
9915        test_git(&path, &["commit", "-m", "recovery commit"]);
9916        let recovery = test_git(&path, &["rev-parse", "HEAD"]);
9917        test_git(&path, &["reset", "--hard", "main"]);
9918
9919        assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
9920        test_git(
9921            &path,
9922            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9923        );
9924    }
9925
9926    #[test]
9927    fn a_review_ref_reflog_only_commit_prevents_deletion() {
9928        let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
9929        let local_ref = review_ref(921);
9930        let original = test_git(&path, &["rev-parse", &local_ref]);
9931        let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
9932        let recovery = test_git(
9933            &path,
9934            &[
9935                "commit-tree",
9936                tree.trim(),
9937                "-p",
9938                original.trim(),
9939                "-m",
9940                "review ref recovery",
9941            ],
9942        );
9943        test_git(
9944            &path,
9945            &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
9946        );
9947        test_git(
9948            &path,
9949            &["update-ref", &local_ref, original.trim(), recovery.trim()],
9950        );
9951
9952        assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
9953        assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
9954        test_git(
9955            &path,
9956            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9957        );
9958    }
9959
9960    #[test]
9961    fn an_unpublished_commit_message_draft_is_recoverable() {
9962        let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
9963        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9964        let git_dir = if raw.is_absolute() {
9965            raw
9966        } else {
9967            path.join(raw)
9968        };
9969        std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
9970
9971        assert!(repository_has_recoverable_work(&path, true).unwrap());
9972        assert_eq!(
9973            "unique recovery draft\n",
9974            std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
9975        );
9976    }
9977
9978    #[test]
9979    fn a_changed_review_worktree_is_retained_after_a_checked_read() {
9980        let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
9981        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9982
9983        let error = repo
9984            .release_review_worktree_checked(902, &checkpoint)
9985            .unwrap_err();
9986
9987        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9988        assert!(error.to_string().contains("kept for recovery"), "{error}");
9989        assert_eq!(
9990            "recover me\n",
9991            std::fs::read_to_string(path.join("README.md")).unwrap()
9992        );
9993        repo.release_review_worktree(902);
9994    }
9995
9996    #[test]
9997    fn a_review_commit_is_retained_after_a_checked_read() {
9998        let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
9999        std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
10000        test_git(&path, &["add", "review-note.txt"]);
10001        test_git(&path, &["commit", "-m", "local review recovery"]);
10002        let head = test_git(&path, &["rev-parse", "HEAD"]);
10003
10004        let error = repo
10005            .release_review_worktree_checked(903, &checkpoint)
10006            .unwrap_err();
10007
10008        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10009        assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
10010        assert_eq!(
10011            "recover me\n",
10012            std::fs::read_to_string(path.join("review-note.txt")).unwrap()
10013        );
10014        repo.release_review_worktree(903);
10015    }
10016
10017    /// The read is no longer what refuses: a file the call created and Git was
10018    /// told to ignore is its own output. Removal still refuses, so the file is
10019    /// there to look at, which is the part that mattered.
10020    #[test]
10021    fn an_ignored_review_file_is_retained_after_a_checked_read() {
10022        let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
10023        std::fs::create_dir_all(path.join("generated")).unwrap();
10024        std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
10025
10026        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10027            .expect("its own output is not a change");
10028        let error = repo
10029            .release_review_worktree_checked(904, &checkpoint)
10030            .unwrap_err();
10031
10032        assert!(error.to_string().contains("recoverable"), "{error}");
10033        assert_eq!(
10034            "recover me\n",
10035            std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
10036        );
10037        repo.release_review_worktree(904);
10038    }
10039
10040    #[test]
10041    /// The checkpoint no longer refuses this: a path the project ignores is out
10042    /// of scope for a call that only read. What keeps the file is the gate that
10043    /// actually deletes the checkout, which refuses any ignored file outside a
10044    /// known build directory. That is the check with the stake in it, and it is
10045    /// the one that has to hold.
10046    fn a_preexisting_ignored_review_file_change_is_retained() {
10047        let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
10048        std::fs::create_dir_all(path.join("generated")).unwrap();
10049        let ignored = path.join("generated/recovery.txt");
10050        std::fs::write(&ignored, "before\n").unwrap();
10051        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10052        std::fs::write(&ignored, "after!\n").unwrap();
10053
10054        let error = repo
10055            .release_review_worktree_checked(905, &checkpoint)
10056            .unwrap_err();
10057
10058        assert!(error.to_string().contains("recoverable"), "{error}");
10059        assert!(path.exists());
10060        assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
10061        repo.release_review_worktree(905);
10062    }
10063
10064    #[test]
10065    fn a_preexisting_ignored_review_file_prevents_checked_removal() {
10066        let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
10067        std::fs::create_dir_all(path.join("generated")).unwrap();
10068        let ignored = path.join("generated/recovery.txt");
10069        std::fs::write(&ignored, "keep me\n").unwrap();
10070        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10071
10072        let error = repo
10073            .release_review_worktree_checked(906, &checkpoint)
10074            .unwrap_err();
10075
10076        assert!(error.to_string().contains("recoverable"), "{error}");
10077        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
10078    }
10079
10080    #[test]
10081    fn overwriting_a_preexisting_untracked_file_is_detected() {
10082        let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
10083        let untracked = path.join("notes.txt");
10084        std::fs::write(&untracked, "before\n").unwrap();
10085        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10086        std::fs::write(&untracked, "after!\n").unwrap();
10087
10088        let error = repo
10089            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10090            .unwrap_err();
10091
10092        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10093        assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
10094    }
10095
10096    #[test]
10097    fn an_assume_unchanged_edit_is_detected() {
10098        let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
10099        test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
10100        std::fs::write(path.join("README.md"), "hidden\n").unwrap();
10101
10102        let error = repo
10103            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10104            .unwrap_err();
10105
10106        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10107        assert_eq!(
10108            "hidden\n",
10109            std::fs::read_to_string(path.join("README.md")).unwrap()
10110        );
10111    }
10112
10113    #[test]
10114    fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
10115        let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
10116        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
10117        test_git(&path, &["add", "README.md"]);
10118        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10119
10120        let error = repo
10121            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10122            .unwrap_err();
10123
10124        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10125        assert_eq!(
10126            b"seed\r\n",
10127            std::fs::read(path.join("README.md")).unwrap().as_slice()
10128        );
10129    }
10130
10131    #[cfg(unix)]
10132    #[test]
10133    fn a_mode_edit_is_detected_when_filemode_is_disabled() {
10134        use std::os::unix::fs::PermissionsExt;
10135
10136        let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
10137        test_git(&path, &["config", "core.filemode", "false"]);
10138        let readme = path.join("README.md");
10139        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
10140        permissions.set_mode(0o755);
10141        std::fs::set_permissions(&readme, permissions).unwrap();
10142        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10143
10144        let error = repo
10145            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10146            .unwrap_err();
10147
10148        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10149        assert_eq!(
10150            0o755,
10151            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
10152        );
10153    }
10154
10155    #[test]
10156    fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
10157        let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
10158        std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
10159        test_git(&path, &["add", ".gitattributes"]);
10160        test_git(&path, &["commit", "-m", "select data filter"]);
10161        let baseline = repo.worktree_baseline(&path).unwrap();
10162        std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
10163
10164        assert!(repo
10165            .commit_pending_changes(&path, &baseline, "change data", "change data")
10166            .unwrap());
10167        let error = repo
10168            .refuse_unrepresented_tracked_changes(&path, &baseline)
10169            .unwrap_err();
10170
10171        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10172        assert_eq!(
10173            "secret: recover me\nnew\n",
10174            std::fs::read_to_string(path.join("data.txt")).unwrap()
10175        );
10176        assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
10177    }
10178
10179    #[test]
10180    fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
10181        let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
10182        std::fs::create_dir_all(path.join("target")).unwrap();
10183        let untracked = path.join("target/user.yaml");
10184        std::fs::write(&untracked, "user data\n").unwrap();
10185        let baseline = repo.worktree_baseline(&path).unwrap();
10186        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10187
10188        assert!(repo
10189            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10190            .unwrap());
10191
10192        assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
10193        assert_eq!(
10194            "?? target/user.yaml\n",
10195            test_git(&path, &["status", "--short", "--untracked-files=all"])
10196        );
10197        assert!(test_git(
10198            &path,
10199            &[
10200                "ls-tree",
10201                "-r",
10202                "--name-only",
10203                "HEAD",
10204                "--",
10205                "target/user.yaml"
10206            ]
10207        )
10208        .is_empty());
10209    }
10210
10211    #[test]
10212    fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
10213        let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
10214        std::fs::create_dir_all(path.join("target")).unwrap();
10215        let untracked = path.join("target/user.yaml");
10216        std::fs::write(&untracked, "before\n").unwrap();
10217        let baseline = repo.worktree_baseline(&path).unwrap();
10218        let before = test_git(&path, &["rev-parse", "HEAD"]);
10219        std::fs::write(&untracked, "after\n").unwrap();
10220        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10221
10222        let error = repo
10223            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10224            .unwrap_err();
10225
10226        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10227        assert!(error.to_string().contains("target/user.yaml"), "{error}");
10228        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
10229        assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
10230        assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
10231    }
10232
10233    #[test]
10234    fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
10235        let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
10236        let baseline = repo.worktree_baseline(&path).unwrap();
10237        std::fs::create_dir_all(path.join("target")).unwrap();
10238        std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
10239
10240        assert!(repo
10241            .commit_pending_changes(&path, &baseline, "add file", "add file")
10242            .unwrap());
10243
10244        assert_eq!(
10245            "new file\n",
10246            test_git(&path, &["show", "HEAD:target/new.txt"])
10247        );
10248        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10249    }
10250
10251    #[test]
10252    fn a_managed_commit_accepts_a_custom_output_rebuild() {
10253        let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
10254        exclude_paths(&repo, &["manager/public/"]);
10255        let output = path.join("manager/public/assets");
10256        std::fs::create_dir_all(&output).unwrap();
10257        std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
10258        std::fs::write(output.join("index.css"), "old styles\n").unwrap();
10259        let baseline = repo.worktree_baseline(&path).unwrap();
10260        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10261        std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
10262        std::fs::remove_file(output.join("old.js")).unwrap();
10263        std::fs::write(output.join("index.css"), "new styles\n").unwrap();
10264        std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
10265
10266        assert!(repo
10267            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10268            .unwrap());
10269        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10270        repo.refuse_changed_existing_untracked(&path, &baseline)
10271            .unwrap();
10272        repo.refuse_unrepresented_tracked_changes(&path, &baseline)
10273            .unwrap();
10274
10275        assert_eq!(
10276            "tracked change\n",
10277            test_git(&path, &["show", "HEAD:README.md"])
10278        );
10279        assert_eq!(
10280            "new source\n",
10281            test_git(&path, &["show", "HEAD:new-source.txt"])
10282        );
10283        assert!(test_git(
10284            &path,
10285            &[
10286                "ls-tree",
10287                "-r",
10288                "--name-only",
10289                "HEAD",
10290                "--",
10291                "manager/public"
10292            ]
10293        )
10294        .is_empty());
10295        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10296        assert!(!output.join("old.js").exists());
10297        assert_eq!(
10298            "new styles\n",
10299            std::fs::read_to_string(output.join("index.css")).unwrap()
10300        );
10301        assert!(repository_has_recoverable_work(&path, true).unwrap());
10302        repo.release_review_worktree(912);
10303        assert!(output.join("index-DUym-Rfj.js").exists());
10304    }
10305
10306    #[test]
10307    fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
10308        let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
10309        std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
10310        let baseline = repo.worktree_baseline(&path).unwrap();
10311        std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
10312        std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
10313
10314        let error = repo
10315            .commit_pending_changes(&path, &baseline, "change", "change")
10316            .unwrap_err();
10317
10318        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10319        assert!(error.to_string().contains("notes.txt"), "{error}");
10320    }
10321
10322    #[test]
10323    fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
10324        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
10325        let baseline = repo.worktree_baseline(&path).unwrap();
10326        let nested = path.join("generated/repro");
10327        std::fs::create_dir_all(&nested).unwrap();
10328        test_git(&nested, &["init"]);
10329        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10330
10331        assert!(repo
10332            .commit_pending_changes(&path, &baseline, "change", "change")
10333            .unwrap());
10334        assert!(!repo.has_uncommitted_changes(&path).unwrap());
10335        assert!(repository_has_recoverable_work(&path, true).unwrap());
10336        repo.release_review_worktree(927);
10337        assert!(nested.join(".git").exists());
10338    }
10339
10340    #[test]
10341    fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
10342        let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
10343        test_git(&path, &["config", "core.autocrlf", "true"]);
10344        std::fs::write(path.join("README.md"), "seed\n").unwrap();
10345        assert_eq!(
10346            test_git(&path, &["hash-object", "README.md"]).trim(),
10347            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
10348        );
10349
10350        assert!(repository_has_recoverable_work(&path, true).unwrap());
10351        assert_eq!(
10352            "seed\n",
10353            std::fs::read_to_string(path.join("README.md")).unwrap()
10354        );
10355    }
10356
10357    #[test]
10358    fn autocrlf_input_overrides_a_crlf_core_eol() {
10359        let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
10360        test_git(&path, &["config", "core.autocrlf", "input"]);
10361        test_git(&path, &["config", "core.eol", "crlf"]);
10362        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
10363        assert_eq!(
10364            test_git(&path, &["hash-object", "README.md"]).trim(),
10365            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
10366        );
10367
10368        assert!(repository_has_recoverable_work(&path, true).unwrap());
10369        assert_eq!(
10370            b"seed\r\n",
10371            std::fs::read(path.join("README.md")).unwrap().as_slice()
10372        );
10373    }
10374
10375    #[cfg(unix)]
10376    #[test]
10377    fn a_non_executable_permission_change_is_recoverable() {
10378        use std::os::unix::fs::PermissionsExt;
10379
10380        let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
10381        let readme = path.join("README.md");
10382        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
10383        permissions.set_mode(0o600);
10384        std::fs::set_permissions(&readme, permissions).unwrap();
10385        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
10386
10387        let error = repo
10388            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10389            .unwrap_err();
10390
10391        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10392        assert!(repository_has_recoverable_work(&path, true).unwrap());
10393        assert_eq!(
10394            0o600,
10395            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
10396        );
10397    }
10398
10399    #[cfg(unix)]
10400    #[test]
10401    fn a_managed_commit_skips_signing_and_hooks() {
10402        use std::os::unix::fs::PermissionsExt;
10403
10404        let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
10405        let common = common_git_dir(&path).unwrap();
10406        let hook = common.join("hooks/pre-commit");
10407        let marker = fixture.root.join("hook-ran");
10408        std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
10409        std::fs::write(
10410            &hook,
10411            format!(
10412                "#!/bin/sh\nprintf ran > {}\nexit 1\n",
10413                sh_quote(marker.to_str().unwrap())
10414            ),
10415        )
10416        .unwrap();
10417        let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
10418        permissions.set_mode(0o755);
10419        std::fs::set_permissions(&hook, permissions).unwrap();
10420        test_git(&path, &["config", "commit.gpgsign", "true"]);
10421        test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
10422        std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
10423        test_git(&path, &["add", "managed.txt"]);
10424
10425        repo.commit_staged_changes(&path, "record managed change")
10426            .unwrap();
10427
10428        assert!(!marker.exists());
10429        assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
10430    }
10431
10432    #[test]
10433    fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
10434        let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
10435        std::fs::write(
10436            path.join(".gitattributes"),
10437            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
10438        )
10439        .unwrap();
10440        test_git(&path, &["add", ".gitattributes"]);
10441        test_git(&path, &["commit", "-m", "select automatic text"]);
10442        test_git(&path, &["config", "core.autocrlf", "true"]);
10443
10444        assert!(repository_has_recoverable_work(&path, true).unwrap());
10445    }
10446
10447    #[test]
10448    fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
10449        let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
10450        std::fs::write(
10451            path.join(".gitattributes"),
10452            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
10453        )
10454        .unwrap();
10455        test_git(&path, &["add", ".gitattributes"]);
10456        test_git(&path, &["commit", "-m", "select ident expansion"]);
10457        std::fs::write(path.join("README.md"), "seed\n").unwrap();
10458
10459        assert!(repository_has_recoverable_work(&path, true).unwrap());
10460    }
10461
10462    /// Add ignore rules the way SPAR does, without a tracked change the
10463    /// worktree would then be kept for.
10464    /// The failure this whole rule was rewritten for: a reviewer ran the
10465    /// project's binding generator to check a finding, and the lockfile it left
10466    /// beside its own manifest matched no directory name spar knew.
10467    #[test]
10468    fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
10469        let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
10470        exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
10471        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10472        std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
10473        std::fs::write(
10474            path.join("bindings/uniffi-bindgen/Cargo.lock"),
10475            "# generated\n",
10476        )
10477        .unwrap();
10478
10479        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10480            .expect("a path the project ignores is the call's own output");
10481    }
10482
10483    #[test]
10484    fn an_attribute_file_under_build_output_is_not_a_filter_change() {
10485        let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
10486        exclude_paths(&repo, &["target/"]);
10487        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10488        std::fs::create_dir_all(path.join("target/package")).unwrap();
10489        std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
10490
10491        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10492            .expect("an ignored attribute file governs only ignored paths");
10493        assert!(
10494            !repo.has_uncommitted_changes(&path).unwrap(),
10495            "ignored dependency attributes must not stop the next review"
10496        );
10497    }
10498
10499    #[test]
10500    fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
10501        let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
10502        std::fs::create_dir_all(path.join("source")).unwrap();
10503        std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
10504
10505        assert!(repo.has_uncommitted_changes(&path).unwrap());
10506    }
10507
10508    #[test]
10509    fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
10510        for source in ["repository", "nested", "local", "global"] {
10511            let (_fixture, repo, path, _initial) = review_fixture(source, 945);
10512            std::fs::create_dir_all(path.join("manager")).unwrap();
10513            match source {
10514                "repository" => {
10515                    std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
10516                        .unwrap();
10517                    test_git(&path, &["add", ".gitignore"]);
10518                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
10519                }
10520                "nested" => {
10521                    std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
10522                    test_git(&path, &["add", "manager/.gitignore"]);
10523                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
10524                }
10525                "local" => exclude_paths(&repo, &["manager/scratch/"]),
10526                "global" => {
10527                    let excludes = repo.root().parent().unwrap().join("global-ignore");
10528                    std::fs::write(&excludes, "manager/scratch/\n").unwrap();
10529                    test_git(
10530                        &path,
10531                        &["config", "core.excludesFile", excludes.to_str().unwrap()],
10532                    );
10533                }
10534                _ => unreachable!(),
10535            }
10536            let output = path.join("manager/scratch");
10537            std::fs::create_dir_all(&output).unwrap();
10538            std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
10539            std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
10540            let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10541            std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
10542            std::fs::remove_file(output.join(".DS_Store")).unwrap();
10543            for file in [
10544                ".gitattributes",
10545                "Cargo.lock",
10546                "font.woff2",
10547                "bundle.js",
10548                "cache.db",
10549            ] {
10550                std::fs::write(output.join(file), "generated\n").unwrap();
10551            }
10552            #[cfg(unix)]
10553            std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
10554            let nested = output.join("dependency");
10555            std::fs::create_dir_all(&nested).unwrap();
10556            test_git(&nested, &["init"]);
10557            repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10558                .unwrap_or_else(|error| panic!("{source}: {error}"));
10559            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10560
10561            let baseline = repo.worktree_baseline(&path).unwrap();
10562            std::fs::write(path.join("README.md"), "source fix\n").unwrap();
10563            std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
10564            std::fs::remove_file(output.join("Cargo.lock")).unwrap();
10565            std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
10566            std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
10567            assert!(repo
10568                .commit_pending_changes(&path, &baseline, "fix source", "fix source")
10569                .unwrap_or_else(|error| panic!("{source}: {error}")));
10570            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10571            assert!(test_git(
10572                &path,
10573                &[
10574                    "ls-tree",
10575                    "-r",
10576                    "--name-only",
10577                    "HEAD",
10578                    "--",
10579                    "manager/scratch"
10580                ]
10581            )
10582            .is_empty());
10583            assert!(
10584                repository_has_recoverable_work(&path, true).unwrap(),
10585                "{source}"
10586            );
10587            repo.release_review_worktree(945);
10588            assert_eq!(
10589                "LOCAL=updated\n",
10590                std::fs::read_to_string(output.join(".env.local")).unwrap()
10591            );
10592            assert!(nested.join(".git").exists(), "{source}");
10593        }
10594    }
10595
10596    /// Two reviewers share one checkout. The marker written when the first
10597    /// one's answer was refused used to be a brand new untracked file in the
10598    /// snapshot the second one was compared against.
10599    #[test]
10600    fn a_recovery_marker_is_not_a_change_to_the_worktree() {
10601        let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
10602        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10603        let _ = uncertain_worktree_change(&path, "an earlier call was refused");
10604
10605        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10606            .expect("spar's own marker is not something a call did");
10607    }
10608
10609    #[test]
10610    fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
10611        let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
10612
10613        let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
10614            .expect("a path a build removed is not an unreadable worktree");
10615
10616        assert_eq!(vanished_untracked_file(), gone);
10617    }
10618
10619    /// A path that goes is still a path that was there. Dropping it from the
10620    /// snapshot would let it pass for one the call never touched.
10621    #[test]
10622    fn a_vanished_path_is_not_recorded_as_absent() {
10623        let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
10624        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
10625        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10626        std::fs::remove_file(path.join("notes.txt")).unwrap();
10627
10628        let error = repo
10629            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10630            .unwrap_err();
10631
10632        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10633    }
10634
10635    fn exclude_paths(repo: &Repo, lines: &[&str]) {
10636        use std::io::Write;
10637        let path = repo.root().join(".git").join("info").join("exclude");
10638        let mut file = std::fs::OpenOptions::new()
10639            .create(true)
10640            .append(true)
10641            .open(&path)
10642            .unwrap();
10643        for line in lines {
10644            writeln!(file, "{line}").unwrap();
10645        }
10646    }
10647
10648    #[test]
10649    fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
10650        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
10651        exclude_paths(&repo, &["dist/"]);
10652        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10653        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10654        let baseline = repo.worktree_baseline(&path).unwrap();
10655        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10656        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10657        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10658        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10659
10660        assert!(repo
10661            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10662            .unwrap());
10663        assert_eq!(
10664            "tracked change\n",
10665            std::fs::read_to_string(path.join("README.md")).unwrap()
10666        );
10667    }
10668
10669    #[test]
10670    fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
10671        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
10672        exclude_paths(&repo, &["dist/"]);
10673        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10674        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10675        std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
10676        let baseline = repo.worktree_baseline(&path).unwrap();
10677        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10678        std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
10679        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10680        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10681
10682        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10683        repo.refuse_changed_existing_untracked(&path, &baseline)
10684            .unwrap();
10685    }
10686
10687    #[test]
10688    fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
10689        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
10690        exclude_paths(&repo, &["dist/"]);
10691        std::fs::create_dir_all(path.join("dist")).unwrap();
10692        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10693        let baseline = repo.worktree_baseline(&path).unwrap();
10694        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10695        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10696
10697        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10698        repo.refuse_changed_existing_untracked(&path, &baseline)
10699            .unwrap();
10700    }
10701
10702    /// The run this comes from: a reviewer rebuilt a project whose build output
10703    /// lands in `manager/public/assets/`, which no directory list will ever
10704    /// name. The guard inside the call kept the review. This check then called
10705    /// the same thirteen files unrepresentable and ended the round, five
10706    /// minutes of review thrown away over a path Git had been told to disown.
10707    /// The run this comes from: `gh pr merge --delete-branch` could not delete
10708    /// `issue-784` because `.spar-worktrees/issue-784`, left by the run that
10709    /// opened the pull request, still had it checked out.
10710    #[test]
10711    fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
10712        let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
10713        let held = repo.root().join(WORKTREE_DIR).join("issue-950");
10714        std::fs::create_dir_all(held.parent().unwrap()).unwrap();
10715        test_git(
10716            repo.root(),
10717            &[
10718                "worktree",
10719                "add",
10720                "-b",
10721                "issue-950",
10722                held.to_str().unwrap(),
10723                "main",
10724            ],
10725        );
10726
10727        let found = repo
10728            .worktree_holding("issue-950")
10729            .expect("the branch is checked out somewhere");
10730
10731        assert!(found.ends_with("issue-950"), "{}", found.display());
10732        assert_eq!(None, repo.worktree_holding("issue-951"));
10733    }
10734
10735    #[test]
10736    fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
10737        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
10738        exclude_paths(&repo, &["manager/public/assets/"]);
10739        std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
10740        let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
10741        std::fs::write(&bundle, "first build\n").unwrap();
10742        let baseline = repo.worktree_baseline(&path).unwrap();
10743        std::fs::write(&bundle, "second build\n").unwrap();
10744        std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
10745
10746        repo.refuse_read_only_leavings(&path, &baseline).unwrap();
10747        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10748    }
10749
10750    /// A repro test a reviewer left behind is moved out with its path kept,
10751    /// and the checkout matches its checkpoint again.
10752    #[test]
10753    fn a_file_a_reviewer_created_is_moved_aside_and_the_checkout_is_whole_again() {
10754        let (_fixture, repo, path, checkpoint) = review_fixture("scratch-aside", 945);
10755        std::fs::create_dir_all(path.join("tests")).unwrap();
10756        std::fs::write(path.join("tests/zz-repro.test.ts"), "repro\n").unwrap();
10757
10758        repo.set_aside_review_scratch(&path, &checkpoint, 945);
10759
10760        assert!(!path.join("tests/zz-repro.test.ts").exists());
10761        let shelves: Vec<PathBuf> = std::fs::read_dir(repo.root().join(".spar/scratch"))
10762            .unwrap()
10763            .flatten()
10764            .map(|entry| entry.path())
10765            .collect();
10766        assert_eq!(1, shelves.len(), "{shelves:?}");
10767        assert_eq!(
10768            "repro\n",
10769            std::fs::read_to_string(shelves[0].join("tests/zz-repro.test.ts")).unwrap()
10770        );
10771        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10772            .unwrap();
10773    }
10774
10775    /// Only what was not there before is moved. A rewritten tracked file, or
10776    /// an untracked one that was already there, stays put for the check to
10777    /// refuse.
10778    #[test]
10779    fn only_a_file_that_was_not_there_is_moved_aside() {
10780        let (_fixture, repo, path, _checkpoint) = review_fixture("scratch-only-new", 946);
10781        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
10782        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10783        std::fs::write(path.join("README.md"), "rewritten\n").unwrap();
10784        std::fs::write(path.join("notes.txt"), "after\n").unwrap();
10785
10786        repo.set_aside_review_scratch(&path, &checkpoint, 946);
10787
10788        assert_eq!(
10789            "rewritten\n",
10790            std::fs::read_to_string(path.join("README.md")).unwrap()
10791        );
10792        assert_eq!(
10793            "after\n",
10794            std::fs::read_to_string(path.join("notes.txt")).unwrap()
10795        );
10796        assert!(!repo.root().join(".spar/scratch").exists());
10797        let error = repo
10798            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10799            .unwrap_err();
10800        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10801        assert!(error.to_string().contains("README.md"), "{error}");
10802        assert!(error.to_string().contains("notes.txt"), "{error}");
10803    }
10804
10805    #[test]
10806    fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
10807        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
10808        exclude_paths(&repo, &["dist/"]);
10809        let baseline = repo.worktree_baseline(&path).unwrap();
10810        std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
10811
10812        let error = repo
10813            .refuse_read_only_leavings(&path, &baseline)
10814            .unwrap_err();
10815
10816        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10817        assert!(error.to_string().contains("scratch.md"), "{error}");
10818    }
10819
10820    #[test]
10821    fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
10822        let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
10823        exclude_paths(&repo, &["dist/", ".env.local"]);
10824        std::fs::create_dir_all(path.join("dist")).unwrap();
10825        let baseline = repo.worktree_baseline(&path).unwrap();
10826        std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
10827        std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
10828
10829        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10830        assert!(repository_has_recoverable_work(&path, true).unwrap());
10831    }
10832
10833    #[test]
10834    fn a_read_only_inspection_may_rebuild_generated_output() {
10835        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
10836        exclude_paths(&repo, &["dist/"]);
10837        std::fs::create_dir_all(path.join("dist")).unwrap();
10838        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10839        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10840        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10841        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10842
10843        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10844            .unwrap();
10845    }
10846
10847    #[test]
10848    fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
10849        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
10850        exclude_paths(&repo, &["dist/", ".env.local"]);
10851        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10852        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10853        std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
10854
10855        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10856            .unwrap();
10857    }
10858
10859    #[test]
10860    fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
10861        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
10862        exclude_paths(&repo, &["dist/", ".env.local"]);
10863        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10864        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10865        std::fs::remove_file(path.join(".env.local")).unwrap();
10866
10867        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10868            .unwrap();
10869    }
10870
10871    #[test]
10872    fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
10873        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
10874        exclude_paths(&repo, &["dist/"]);
10875        std::fs::write(path.join("notes.md"), "before\n").unwrap();
10876        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10877        std::fs::write(path.join("notes.md"), "after\n").unwrap();
10878
10879        let error = repo
10880            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10881            .unwrap_err();
10882
10883        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10884        assert!(error.to_string().contains("notes.md"), "{error}");
10885    }
10886
10887    #[test]
10888    fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
10889        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
10890        exclude_paths(&repo, &["dist/", "local.env"]);
10891        std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
10892        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10893        // Stop ignoring it, so the same bytes are now an ordinary untracked
10894        // file. What a path is counts, not only whether it moved.
10895        std::fs::write(
10896            repo.root().join(".git").join("info").join("exclude"),
10897            "dist/\n",
10898        )
10899        .unwrap();
10900
10901        let error = repo
10902            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10903            .unwrap_err();
10904
10905        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10906        assert!(error.to_string().contains("local.env"), "{error}");
10907    }
10908
10909    #[test]
10910    fn a_read_only_inspection_names_the_tracked_file_that_moved() {
10911        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
10912        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10913        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
10914
10915        let error = repo
10916            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10917            .unwrap_err();
10918
10919        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10920        assert!(error.to_string().contains("README.md"), "{error}");
10921    }
10922
10923    #[test]
10924    fn build_output_alone_does_not_keep_a_worktree() {
10925        let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
10926        exclude_paths(&repo, &["target/", "dist/"]);
10927        std::fs::create_dir_all(path.join("target/debug")).unwrap();
10928        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10929        std::fs::create_dir_all(path.join("dist/cli")).unwrap();
10930        std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
10931
10932        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10933        repo.release_review_worktree(933);
10934
10935        assert!(!path.exists());
10936    }
10937
10938    #[test]
10939    fn finder_metadata_alone_does_not_keep_a_worktree() {
10940        let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
10941        exclude_paths(&repo, &[".DS_Store"]);
10942        std::fs::create_dir_all(path.join("src")).unwrap();
10943        std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
10944        std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
10945
10946        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10947        repo.release_review_worktree(937);
10948
10949        assert!(!path.exists());
10950    }
10951
10952    #[test]
10953    fn a_fetch_record_alone_does_not_keep_a_worktree() {
10954        let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
10955        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
10956        let git_dir = if raw.is_absolute() {
10957            raw
10958        } else {
10959            path.join(raw)
10960        };
10961        let head = test_git(&path, &["rev-parse", "HEAD"]);
10962        std::fs::write(
10963            git_dir.join("FETCH_HEAD"),
10964            format!("{}\t\tbranch 'main' of origin\n", head.trim()),
10965        )
10966        .unwrap();
10967
10968        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10969        repo.release_review_worktree(938);
10970
10971        assert!(!path.exists());
10972    }
10973
10974    #[test]
10975    fn an_ignored_file_outside_build_output_keeps_a_worktree() {
10976        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
10977        exclude_paths(&repo, &["target/", ".env.local"]);
10978        std::fs::create_dir_all(path.join("target/debug")).unwrap();
10979        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10980        std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
10981
10982        assert!(repository_has_recoverable_work(&path, true).unwrap());
10983        repo.release_review_worktree(934);
10984
10985        assert_eq!(
10986            "TOKEN=keep me\n",
10987            std::fs::read_to_string(path.join(".env.local")).unwrap()
10988        );
10989    }
10990
10991    #[test]
10992    fn a_repository_nested_in_build_output_keeps_a_worktree() {
10993        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
10994        exclude_paths(&repo, &["node_modules/"]);
10995        let nested = path.join("node_modules/local-dep");
10996        std::fs::create_dir_all(&nested).unwrap();
10997        test_git(&nested, &["init"]);
10998        std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
10999
11000        assert!(repository_has_recoverable_work(&path, true).unwrap());
11001        repo.release_review_worktree(935);
11002
11003        assert!(nested.join(".git").exists());
11004    }
11005
11006    #[test]
11007    fn an_ordinary_untracked_file_keeps_a_worktree() {
11008        let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
11009        std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
11010
11011        assert!(repository_has_recoverable_work(&path, true).unwrap());
11012        repo.release_review_worktree(936);
11013
11014        assert_eq!(
11015            "somebody's notes\n",
11016            std::fs::read_to_string(path.join("notes.md")).unwrap()
11017        );
11018    }
11019
11020    #[test]
11021    fn a_legacy_crlf_checkout_is_retained_conservatively() {
11022        let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
11023        std::fs::write(
11024            path.join(".gitattributes"),
11025            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
11026        )
11027        .unwrap();
11028        test_git(&path, &["add", ".gitattributes"]);
11029        test_git(&path, &["commit", "-m", "select legacy line endings"]);
11030
11031        assert!(repository_has_recoverable_work(&path, true).unwrap());
11032    }
11033
11034    #[test]
11035    fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
11036        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
11037        let nested = path.join("tracked");
11038        std::fs::create_dir_all(&nested).unwrap();
11039        std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
11040        test_git(&path, &["add", "tracked/seed.txt"]);
11041        test_git(&path, &["commit", "-m", "add tracked directory"]);
11042        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
11043        test_git(&nested, &["init"]);
11044
11045        let error = repo
11046            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
11047            .unwrap_err();
11048
11049        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11050        assert!(error.to_string().contains("Git entry"), "{error}");
11051        assert!(nested.join(".git").exists());
11052    }
11053
11054    /// A reviewer that clones a reproduction into ignored build output used to
11055    /// lose the whole review. The state check refused every Git entry it had
11056    /// not put there, including the one Git reports as a nested checkout and
11057    /// the untracked snapshot already records by identity.
11058    #[test]
11059    fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
11060        let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
11061        exclude_paths(&repo, &["target/"]);
11062        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
11063        let repro = path.join("target/pr918-repro");
11064        std::fs::create_dir_all(&repro).unwrap();
11065        test_git(&repro, &["init"]);
11066        std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
11067
11068        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
11069            .unwrap();
11070
11071        assert!(repository_has_recoverable_work(&path, true).unwrap());
11072        assert!(repro.join(".git").exists());
11073    }
11074
11075    /// What sits under a nested checkout is that repository's business, so the
11076    /// scan stops at its directory rather than reading a checkout of its own
11077    /// as an entry the outer worktree has to account for.
11078    #[test]
11079    fn a_repository_inside_a_nested_checkout_is_left_alone() {
11080        let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
11081        exclude_paths(&repo, &["target/"]);
11082        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
11083        let repro = path.join("target/repro");
11084        let vendored = repro.join("vendor/dep");
11085        std::fs::create_dir_all(&vendored).unwrap();
11086        test_git(&repro, &["init"]);
11087        test_git(&vendored, &["init"]);
11088
11089        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
11090            .unwrap();
11091
11092        assert!(vendored.join(".git").exists());
11093    }
11094
11095    #[test]
11096    fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
11097        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
11098
11099        let state = ignored_untracked_state(repo.root()).unwrap();
11100
11101        let relative = path.strip_prefix(repo.root()).unwrap();
11102        assert!(
11103            state.files.contains_key(relative),
11104            "{:?}",
11105            state.files.keys().collect::<Vec<_>>()
11106        );
11107        assert!(state.is_ignored(relative));
11108    }
11109
11110    #[test]
11111    fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
11112        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
11113        let baseline = repo.worktree_baseline(repo.root()).unwrap();
11114        std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
11115        std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
11116
11117        repo.refuse_new_ignored_files(repo.root(), &baseline)
11118            .unwrap();
11119        repo.refuse_changed_existing_untracked(repo.root(), &baseline)
11120            .unwrap();
11121    }
11122
11123    #[test]
11124    fn deleting_a_resident_worktree_during_a_call_is_refused() {
11125        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
11126        let baseline = repo.worktree_baseline(repo.root()).unwrap();
11127        let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
11128        std::fs::remove_dir_all(&path).unwrap();
11129
11130        let read_error = repo
11131            .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
11132            .unwrap_err();
11133        assert!(
11134            read_error.to_string().contains("review-932"),
11135            "{read_error}"
11136        );
11137        let error = repo
11138            .refuse_new_ignored_files(repo.root(), &baseline)
11139            .unwrap_err();
11140
11141        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11142        assert!(error.to_string().contains("review-932"), "{error}");
11143    }
11144
11145    #[test]
11146    fn a_nested_repository_record_is_read_as_a_plain_path() {
11147        let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
11148        assert_eq!(Path::new("vendor/checkout"), path);
11149        assert!(nested);
11150
11151        let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
11152        assert_eq!(Path::new("vendor/notes.txt"), path);
11153        assert!(!nested);
11154
11155        assert!(untracked_record(b"/", "untracked").is_err());
11156    }
11157
11158    #[cfg(unix)]
11159    #[test]
11160    fn a_non_utf8_git_path_is_preserved_without_loss() {
11161        use std::os::unix::ffi::OsStrExt;
11162
11163        let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
11164
11165        assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
11166    }
11167
11168    #[test]
11169    fn guarded_merge_pins_the_reviewed_head() {
11170        let args = merge_pr_args("36", Some("abc123"), true);
11171        assert_eq!(
11172            vec![
11173                "pr",
11174                "merge",
11175                "36",
11176                "--squash",
11177                "--delete-branch",
11178                "--match-head-commit",
11179                "abc123"
11180            ],
11181            args
11182        );
11183    }
11184
11185    #[test]
11186    fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
11187        let pr = PrRef {
11188            number: 7,
11189            url: "https://example.test/pull/7".into(),
11190            title: "part one".into(),
11191        };
11192        let result = reconcile_pr_creation(
11193            "split-34-1",
11194            Err(crate::error::SparError::new("connection lost")),
11195            Ok(Some(pr)),
11196        )
11197        .unwrap();
11198        assert_eq!(7, result.number);
11199    }
11200
11201    #[test]
11202    fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
11203        let error = reconcile_pr_creation(
11204            "split-34-1",
11205            Err(crate::error::SparError::new("permission denied")),
11206            Ok(None),
11207        )
11208        .unwrap_err();
11209        assert!(error.to_string().contains("permission denied"), "{error}");
11210    }
11211
11212    #[test]
11213    fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
11214        let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
11215        assert!(pr_for_base(text, "split-34-2", "split-34-1")
11216            .unwrap()
11217            .is_none());
11218        let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
11219        assert_eq!(7, found.number);
11220    }
11221
11222    #[test]
11223    fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
11224        let result = reconcile_comment_post(
11225            34,
11226            "the summary",
11227            crate::error::SparError::new("connection lost"),
11228            Ok(vec![serde_json::json!({"body": "the summary"})]),
11229        );
11230        assert!(result.is_ok(), "{result:?}");
11231    }
11232
11233    #[test]
11234    fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
11235        let error = reconcile_comment_post(
11236            34,
11237            "the summary",
11238            crate::error::SparError::new("connection lost"),
11239            Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
11240        )
11241        .unwrap_err();
11242        assert_eq!("connection lost", error.to_string());
11243    }
11244
11245    #[test]
11246    fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
11247        let error = reconcile_comment_post(
11248            34,
11249            "the summary",
11250            crate::error::SparError::new("connection lost"),
11251            Err(crate::error::SparError::new("comments unavailable")),
11252        )
11253        .unwrap_err();
11254        assert!(
11255            error.to_string().contains("could not be verified"),
11256            "{error}"
11257        );
11258        assert!(
11259            error.to_string().contains("comments unavailable"),
11260            "{error}"
11261        );
11262        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11263        assert!(!error.worth_retrying());
11264    }
11265
11266    #[test]
11267    fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
11268        let result = reconcile_issue_edit(
11269            34,
11270            "wanted body",
11271            crate::error::SparError::new("connection lost"),
11272            Ok("wanted body".to_string()),
11273        );
11274        assert!(result.is_ok(), "{result:?}");
11275    }
11276
11277    #[test]
11278    fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
11279        let error = reconcile_issue_edit(
11280            34,
11281            "wanted body",
11282            crate::error::SparError::new("connection lost"),
11283            Err(crate::error::SparError::new("issue unavailable")),
11284        )
11285        .unwrap_err();
11286        assert!(
11287            error.to_string().contains("could not be verified"),
11288            "{error}"
11289        );
11290        assert!(error.to_string().contains("issue unavailable"), "{error}");
11291        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11292        assert!(!error.worth_retrying());
11293    }
11294
11295    #[test]
11296    fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
11297        let found = ExistingIssue {
11298            number: 101,
11299            url: "https://example.test/issues/101".into(),
11300            title: "child".into(),
11301            body: "body".into(),
11302            open: true,
11303        };
11304        let url = reconcile_issue_creation(
11305            "child",
11306            Err(crate::error::SparError::new("connection lost")),
11307            Ok(Some(found)),
11308        )
11309        .unwrap();
11310        assert_eq!("https://example.test/issues/101", url);
11311    }
11312
11313    #[test]
11314    fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
11315        let error = reconcile_issue_creation(
11316            "child",
11317            Err(crate::error::SparError::new("permission denied")),
11318            Ok(None),
11319        )
11320        .unwrap_err();
11321        assert!(error.to_string().contains("permission denied"), "{error}");
11322    }
11323
11324    #[test]
11325    fn an_unverifiable_issue_creation_is_marked_uncertain() {
11326        let error = reconcile_issue_creation(
11327            "child",
11328            Err(crate::error::SparError::new("connection lost")),
11329            Err(crate::error::SparError::new("issues unavailable")),
11330        )
11331        .unwrap_err();
11332        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
11333        assert!(!error.worth_retrying());
11334    }
11335
11336    #[test]
11337    fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
11338        let result = reconcile_failed_split_push(
11339            "split-34-1",
11340            crate::error::SparError::new("connection lost"),
11341            Ok("abc123\n".into()),
11342            Ok("abc123\trefs/heads/split-34-1\n".into()),
11343        );
11344        assert!(result.is_ok(), "{result:?}");
11345    }
11346
11347    #[test]
11348    fn a_split_push_collision_is_definite_and_never_overwrites() {
11349        let error = reconcile_failed_split_push(
11350            "split-34-1",
11351            crate::error::SparError::new("lease rejected"),
11352            Ok("abc123\n".into()),
11353            Ok("def456\trefs/heads/split-34-1\n".into()),
11354        )
11355        .unwrap_err();
11356        assert!(!error.retain_worktree());
11357        assert!(
11358            error.to_string().contains("Nothing was overwritten"),
11359            "{error}"
11360        );
11361    }
11362
11363    #[test]
11364    fn an_unreadable_split_push_result_keeps_the_worktree() {
11365        let error = reconcile_failed_split_push(
11366            "split-34-1",
11367            crate::error::SparError::new("connection lost"),
11368            Ok("abc123\n".into()),
11369            Err(crate::error::SparError::new("origin unavailable")),
11370        )
11371        .unwrap_err();
11372        assert!(error.retain_worktree());
11373        assert!(error.to_string().contains("could not confirm"), "{error}");
11374    }
11375
11376    /// Follow-up deduplication compares a title it computed against the title
11377    /// GitHub stored. If those two transforms can disagree, the check never
11378    /// matches and every review round files another copy of the same issue.
11379    #[test]
11380    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
11381        let repo = repo_for_titles();
11382        for raw in [
11383            "Retry loop spins \u{2014} Retry-After parses to zero",
11384            "plain title",
11385            "  spread   over\nlines  ",
11386            "\u{1F916} Generated with something",
11387            &format!("a \u{2014} {}", "very long title ".repeat(20)),
11388            &"x".repeat(300),
11389            &format!("{} \u{2014} end", "y".repeat(88)),
11390            // Exactly the budget, with two spaceless dashes. The scrub turns
11391            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
11392            // character over budget per dash and a second pass clips again,
11393            // producing a different string. Scrub-then-clip cannot.
11394            &{
11395                let tail = "a\u{2014}b c\u{2014}d";
11396                let pad = Style::default().max_title_chars - tail.chars().count();
11397                format!("{}{tail}", "w".repeat(pad))
11398            },
11399        ] {
11400            let once = repo.clean_title(raw).unwrap();
11401            let twice = repo.clean_title(&once).unwrap();
11402            assert_eq!(once, twice, "not idempotent for {raw:?}");
11403            assert!(
11404                once.chars().count() <= repo.style.max_title_chars,
11405                "over budget: {once:?}"
11406            );
11407            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
11408        }
11409    }
11410
11411    #[test]
11412    fn a_title_with_an_em_dash_survives_as_readable_text() {
11413        let repo = repo_for_titles();
11414        assert_eq!(
11415            "Retry loop spins, Retry-After parses to zero",
11416            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
11417                .unwrap()
11418        );
11419    }
11420
11421    #[test]
11422    fn sh_quote_survives_a_quote() {
11423        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
11424    }
11425
11426    #[test]
11427    fn sh_quote_wraps_a_space() {
11428        assert_eq!(
11429            "'/Applications/My App/spar'",
11430            sh_quote("/Applications/My App/spar")
11431        );
11432    }
11433
11434    #[test]
11435    fn finished_states_are_recognised_case_insensitively() {
11436        assert!(is_finished("MERGED"));
11437        assert!(is_finished("closed"));
11438        assert!(!is_finished("OPEN"));
11439        assert!(!is_finished(""));
11440    }
11441
11442    fn state() -> PersistedState {
11443        PersistedState {
11444            version: 1,
11445            checkpoint: 0,
11446            round: 4,
11447            next_actor: "codex".into(),
11448            status: Status::Pending,
11449            pr_head: "abc123".into(),
11450            ledger: Ledger::new(),
11451            filed: vec![],
11452            open_findings: vec![Finding {
11453                severity: Severity::Blocking,
11454                title: "Unchecked error".into(),
11455                detail: "the failure is discarded".into(),
11456                file: "src/a.rs:12".into(),
11457                ..Finding::default()
11458            }],
11459            disputes: vec![Dispute {
11460                title: "Retry limit".into(),
11461                file: "src/net.rs".into(),
11462                reasoning: "the caller already bounds it".into(),
11463            }],
11464            noted: vec![Finding {
11465                severity: Severity::NonBlocking,
11466                title: "Timeout is fixed".into(),
11467                file: "src/config.rs".into(),
11468                ..Finding::default()
11469            }],
11470        }
11471    }
11472
11473    #[test]
11474    fn a_state_comment_round_trips() {
11475        let body = format!(
11476            "{STATE_MARKER}\n{}\n-->",
11477            serde_json::to_string(&state()).unwrap()
11478        );
11479        let back = parse_state_comment(&body).unwrap();
11480        assert_eq!(4, back.round);
11481        assert_eq!("codex", back.next_actor);
11482        assert_eq!("abc123", back.pr_head);
11483        assert_eq!("Unchecked error", back.open_findings[0].title);
11484        assert_eq!("src/net.rs", back.disputes[0].file);
11485        assert_eq!("Timeout is fixed", back.noted[0].title);
11486    }
11487
11488    #[test]
11489    fn old_state_without_new_lists_still_parses() {
11490        let body = format!(
11491            "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
11492             \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11493        );
11494        let back = parse_state_comment(&body).expect("old state");
11495        assert!(back.open_findings.is_empty());
11496        assert!(back.disputes.is_empty());
11497        assert!(back.noted.is_empty());
11498        assert!(back.pr_head.is_empty());
11499        assert_eq!(0, back.checkpoint);
11500    }
11501
11502    #[test]
11503    fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
11504        let mut local = state();
11505        local.pr_head = "old".into();
11506        local.round = 9;
11507        let mut remote = state();
11508        remote.pr_head = "current".into();
11509        remote.round = 4;
11510
11511        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11512        assert_eq!("current", chosen.pr_head);
11513        assert_eq!(4, chosen.round);
11514    }
11515
11516    #[test]
11517    fn checkpoint_order_breaks_same_round_ties() {
11518        let mut local = state();
11519        local.pr_head = "current".into();
11520        local.round = 4;
11521        local.checkpoint = 8;
11522        let mut remote = local.clone();
11523        remote.checkpoint = 7;
11524        remote.open_findings.clear();
11525
11526        let chosen = choose_state_for_head(vec![local], "current").unwrap();
11527        assert_eq!(8, chosen.checkpoint);
11528
11529        let mut local = state();
11530        local.pr_head = "current".into();
11531        local.round = 4;
11532        local.checkpoint = 8;
11533        let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
11534        assert_eq!(8, chosen.checkpoint);
11535    }
11536
11537    #[test]
11538    fn legacy_same_round_tie_keeps_the_local_checkpoint() {
11539        let mut local = state();
11540        local.pr_head = "current".into();
11541        local.round = 4;
11542        local.open_findings.push(Finding {
11543            title: "local checkpoint".into(),
11544            ..Finding::default()
11545        });
11546        let mut remote = state();
11547        remote.pr_head = "current".into();
11548        remote.round = 4;
11549
11550        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11551        assert_eq!(
11552            "local checkpoint",
11553            chosen.open_findings.last().unwrap().title
11554        );
11555    }
11556
11557    /// It must render as nothing, so PRs are not littered with machine state.
11558    #[test]
11559    fn the_state_block_is_an_html_comment() {
11560        let body = format!(
11561            "{STATE_MARKER}\n{}\n-->",
11562            serde_json::to_string(&state()).unwrap()
11563        );
11564        assert!(body.starts_with("<!--"));
11565        assert!(body.trim_end().ends_with("-->"));
11566        assert!(!body[..body.find('{').unwrap()].contains("-->"));
11567    }
11568
11569    #[test]
11570    fn an_unrelated_json_block_is_not_state() {
11571        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
11572    }
11573
11574    /// Patching and deleting a comment is not something to do on a substring.
11575    ///
11576    /// A maintainer who notices the block and asks about it writes a comment
11577    /// containing `spar:state`, and the loose filter could not tell theirs from
11578    /// spar's. The next `persist` replaced their words with JSON and
11579    /// `spar clean --pr-state` later deleted it.
11580    #[test]
11581    fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
11582        let block = format!(
11583            "{STATE_MARKER}\n{}\n-->",
11584            serde_json::to_string(&state()).unwrap()
11585        );
11586        assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
11587        assert!(
11588            is_state_comment(&block, "Spar-Bot", "spar-bot"),
11589            "a login differs only in case"
11590        );
11591
11592        assert!(
11593            !is_state_comment(
11594                "why is there a `<!-- spar:state` block on this PR?",
11595                "maintainer",
11596                "spar-bot"
11597            ),
11598            "a question about the block was treated as the block"
11599        );
11600        assert!(
11601            !is_state_comment(&block, "maintainer", "spar-bot"),
11602            "somebody else's comment is not spar's to rewrite"
11603        );
11604        assert!(
11605            !is_state_comment(
11606                &format!("here is what spar writes:\n\n{block}"),
11607                "spar-bot",
11608                "spar-bot"
11609            ),
11610            "the marker has to open the body, not appear in it"
11611        );
11612        assert!(
11613            !is_state_comment(
11614                &format!("{STATE_MARKER}\n{{not json\n-->"),
11615                "spar-bot",
11616                "spar-bot"
11617            ),
11618            "a block that does not parse is not state"
11619        );
11620        assert!(
11621            !is_state_comment(&block, "spar-bot", ""),
11622            "an unknown viewer proves nothing about who wrote it"
11623        );
11624    }
11625
11626    #[test]
11627    fn a_malformed_state_comment_is_none_not_a_panic() {
11628        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
11629    }
11630
11631    #[test]
11632    fn atomic_write_leaves_no_temp_file() {
11633        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
11634        let _ = std::fs::remove_dir_all(&dir);
11635        let path = dir.join("state").join("pr-7.json");
11636        write_json_atomic(&path, &state()).unwrap();
11637        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
11638            .unwrap()
11639            .flatten()
11640            .filter_map(|e| e.file_name().to_str().map(str::to_string))
11641            .collect();
11642        assert_eq!(vec!["pr-7.json".to_string()], files);
11643        let _ = std::fs::remove_dir_all(&dir);
11644    }
11645
11646    #[test]
11647    fn atomic_write_overwrites_rather_than_accumulating() {
11648        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
11649        let _ = std::fs::remove_dir_all(&dir);
11650        let path = dir.join("pr-7.json");
11651        for round in 1..4 {
11652            let mut s = state();
11653            s.round = round;
11654            write_json_atomic(&path, &s).unwrap();
11655        }
11656        let back: PersistedState =
11657            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11658        assert_eq!(3, back.round);
11659        let _ = std::fs::remove_dir_all(&dir);
11660    }
11661
11662    #[test]
11663    fn style_from_env_defaults_to_enforcing() {
11664        std::env::remove_var("SPAR_BAN_EM_DASH");
11665        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
11666        let style = style_from_env();
11667        assert!(style.ban_em_dash && style.ban_ai_attribution);
11668        assert!(
11669            !style.terse,
11670            "the commit filter must not truncate a commit message"
11671        );
11672    }
11673
11674    // -- publishing from a fork --------------------------------------------
11675
11676    /// GitHub's refusal is several lines of advice after the one that says
11677    /// what happened, and spar used to report only the last of them.
11678    #[test]
11679    fn a_push_denial_is_read_from_either_transport() {
11680        let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
11681                   fatal: Could not read from remote repository.\n\n\
11682                   Please make sure you have the correct access rights\n\
11683                   and the repository exists.";
11684        let https = "remote: Permission to example/project.git denied to someone.\n\
11685                     fatal: unable to access 'https://github.com/example/project.git/': The \
11686                     requested URL returned error: 403";
11687        let denied = Some("Permission to example/project.git denied to someone.");
11688        assert_eq!(denied, push_denial(ssh));
11689        assert_eq!(denied, push_denial(https));
11690
11691        let stale = "To github.com:example/project.git\n \
11692                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
11693                     error: failed to push some refs to 'github.com:example/project.git'";
11694        assert_eq!(None, push_denial(stale));
11695
11696        let err = push_error(
11697            &PushTarget::Origin,
11698            "issue-7",
11699            &crate::error::SparError::new(ssh),
11700        );
11701        assert_eq!(
11702            "could not push to origin/issue-7. Permission to example/project.git denied to \
11703             someone.",
11704            err.to_string()
11705        );
11706    }
11707
11708    /// An origin whose hook refuses every push with GitHub's wording, a bare
11709    /// repository standing in for the fork, and a checkout on a new branch
11710    /// with one commit to publish.
11711    #[cfg(unix)]
11712    fn denied_fixture(
11713        tag: &str,
11714        fork_when_denied: bool,
11715    ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
11716        use std::os::unix::fs::PermissionsExt;
11717        use std::sync::atomic::{AtomicU32, Ordering};
11718        static NEXT: AtomicU32 = AtomicU32::new(0);
11719        let id = NEXT.fetch_add(1, Ordering::Relaxed);
11720        let root =
11721            std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
11722        let origin = root.join("origin.git");
11723        let fork = root.join("fork.git");
11724        let work = root.join("work");
11725        for dir in [&origin, &fork, &work] {
11726            std::fs::create_dir_all(dir).unwrap();
11727        }
11728        test_git(&origin, &["init", "--bare", "-b", "main"]);
11729        test_git(&fork, &["init", "--bare", "-b", "main"]);
11730        test_git(&work, &["init", "-b", "main"]);
11731        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
11732        test_git(&work, &["config", "user.name", "spar test"]);
11733        test_git(&work, &["config", "commit.gpgsign", "false"]);
11734        std::fs::write(work.join("README.md"), "seed\n").unwrap();
11735        test_git(&work, &["add", "."]);
11736        test_git(&work, &["commit", "-m", "seed"]);
11737        test_git(
11738            &work,
11739            &["remote", "add", "origin", origin.to_str().unwrap()],
11740        );
11741        test_git(&work, &["push", "-q", "origin", "main"]);
11742        // Installed after the seed, so it is only spar's pushes it refuses.
11743        let hook = origin.join("hooks").join("pre-receive");
11744        std::fs::write(
11745            &hook,
11746            "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
11747        )
11748        .unwrap();
11749        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
11750        test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
11751        std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
11752        test_git(&work, &["add", "."]);
11753        test_git(&work, &["commit", "-m", "Fix it"]);
11754
11755        let mut text =
11756            String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
11757        if !fork_when_denied {
11758            text.push_str("[loop]\nfork_when_denied = false\n");
11759        }
11760        let cfg = crate::config::parse(&text).unwrap();
11761        let repo = Repo::open(&work, &cfg).unwrap();
11762        // What `fork` would have asked GitHub for, so no test reaches it.
11763        repo.fork
11764            .set(Fork {
11765                owner: "someone".into(),
11766                full_name: "someone/project".into(),
11767                url: fork.to_str().unwrap().into(),
11768            })
11769            .unwrap();
11770        (ReviewFixture { root }, repo, work, origin, fork)
11771    }
11772
11773    #[cfg(unix)]
11774    #[test]
11775    fn a_new_branch_origin_refuses_is_published_from_the_fork() {
11776        let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
11777        let first = test_git(&work, &["rev-parse", "HEAD"]);
11778
11779        let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11780
11781        assert_eq!("someone:issue-7", target.head("issue-7"));
11782        assert_eq!(
11783            first.trim(),
11784            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11785        );
11786        assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11787            .trim()
11788            .is_empty());
11789        assert_eq!(
11790            WriteSummary {
11791                attempted: 1,
11792                failed: 0
11793            },
11794            repo.write_summary(),
11795            "a refusal the fork answered is not a failed write"
11796        );
11797
11798        // A later round follows the branch to the fork, leasing what it
11799        // published there.
11800        std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
11801        test_git(&work, &["commit", "-qam", "Fix it again"]);
11802        repo.push_to(&work, &target, "issue-7", Some(first.trim()))
11803            .unwrap();
11804        let second = test_git(&work, &["rev-parse", "HEAD"]);
11805        assert_eq!(
11806            second.trim(),
11807            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11808        );
11809    }
11810
11811    /// What a closed pull request leaves on the fork: a branch with nothing
11812    /// a pull request does not hold. Origin's issue branches are rebuilt over
11813    /// in the same case.
11814    #[cfg(unix)]
11815    #[test]
11816    fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
11817        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
11818        test_git(
11819            &work,
11820            &[
11821                "push",
11822                "-q",
11823                fork.to_str().unwrap(),
11824                "main:refs/heads/issue-7",
11825            ],
11826        );
11827        let head = test_git(&work, &["rev-parse", "HEAD"]);
11828
11829        repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11830
11831        assert_eq!(
11832            head.trim(),
11833            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11834        );
11835        assert_eq!(0, repo.write_summary().failed);
11836    }
11837
11838    #[cfg(unix)]
11839    #[test]
11840    fn work_only_the_fork_has_is_not_published_over() {
11841        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
11842        test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
11843        std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
11844        test_git(&work, &["add", "."]);
11845        test_git(&work, &["commit", "-qm", "Somebody's work"]);
11846        test_git(
11847            &work,
11848            &[
11849                "push",
11850                "-q",
11851                fork.to_str().unwrap(),
11852                "elsewhere:refs/heads/issue-7",
11853            ],
11854        );
11855        let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
11856        test_git(&work, &["checkout", "-q", "issue-7"]);
11857
11858        let err = repo
11859            .publish_new_branch(&work, "issue-7", "main")
11860            .unwrap_err()
11861            .to_string();
11862
11863        assert!(
11864            err.contains(
11865                "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
11866            ),
11867            "{err}"
11868        );
11869        assert!(err.contains("--delete issue-7"), "{err}");
11870        assert_eq!(
11871            theirs.trim(),
11872            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11873        );
11874        assert_eq!(
11875            WriteSummary {
11876                attempted: 1,
11877                failed: 1
11878            },
11879            repo.write_summary()
11880        );
11881    }
11882
11883    /// A lease that loses says which ref and why, not git's closing line.
11884    #[test]
11885    fn a_rejected_push_names_the_ref_it_rejected() {
11886        let stale = "To github.com:example/project.git\n \
11887                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
11888                     error: failed to push some refs to 'github.com:example/project.git'";
11889
11890        let err = push_error(
11891            &PushTarget::Origin,
11892            "issue-7",
11893            &crate::error::SparError::new(stale),
11894        )
11895        .to_string();
11896
11897        assert!(
11898            err.starts_with(
11899                "could not push to origin/issue-7. [rejected]        HEAD -> issue-7 (stale info)"
11900            ),
11901            "{err}"
11902        );
11903    }
11904
11905    /// What GitHub answered for a branch no ruleset applies to, a minute
11906    /// before it took the same push.
11907    const COMMIT_REFS_RULES: &str = "To github.com:example/project.git\n \
11908         ! [remote rejected] HEAD -> issue-7 (fatal error in commit_refs_rules)\n\
11909         error: failed to push some refs to 'github.com:example/project.git'";
11910
11911    #[test]
11912    fn a_remote_failing_on_its_own_side_is_worth_pushing_again() {
11913        let transient = [
11914            (
11915                COMMIT_REFS_RULES,
11916                "[remote rejected] HEAD -> issue-7 (fatal error in commit_refs_rules)",
11917            ),
11918            (
11919                " ! [remote rejected] HEAD -> issue-7 (Internal Server Error)",
11920                "[remote rejected] HEAD -> issue-7 (Internal Server Error)",
11921            ),
11922            (
11923                "error: RPC failed; HTTP 502 curl 22 The requested URL returned error: 502\n\
11924                 send-pack: unexpected disconnect while reading sideband packet\n\
11925                 fatal: the remote end hung up unexpectedly",
11926                "error: RPC failed; HTTP 502 curl 22 The requested URL returned error: 502",
11927            ),
11928            (
11929                "error: RPC failed; curl 92 HTTP/2 stream 0 was not closed cleanly: CANCEL (err 8)",
11930                "error: RPC failed; curl 92 HTTP/2 stream 0 was not closed cleanly: CANCEL (err 8)",
11931            ),
11932            (
11933                "kex_exchange_identification: Connection closed by remote host\n\
11934                 fatal: Could not read from remote repository.",
11935                "kex_exchange_identification: Connection closed by remote host",
11936            ),
11937        ];
11938        for (message, line) in transient {
11939            assert_eq!(Some(line), transient_push_failure(message), "{message}");
11940        }
11941
11942        let about_the_push = [
11943            // A lease that lost: the branch moved.
11944            "To github.com:example/project.git\n \
11945             ! [rejected]        HEAD -> issue-7 (stale info)\n\
11946             error: failed to push some refs to 'github.com:example/project.git'",
11947            // A hook or a ruleset that declined it.
11948            " ! [remote rejected] HEAD -> issue-7 (pre-receive hook declined)",
11949            "remote: error: GH013: Repository rule violations found for refs/heads/issue-7.\n \
11950             ! [remote rejected] HEAD -> issue-7 (push declined due to repository rule \
11951             violations)",
11952            // A denial, over either transport.
11953            "ERROR: Permission to example/project.git denied to someone.\n\
11954             fatal: Could not read from remote repository.",
11955            // Too large: git hangs up after it, and the same push is as large
11956            // the next time.
11957            "error: RPC failed; HTTP 413 curl 22 The requested URL returned error: 413\n\
11958             fatal: the remote end hung up unexpectedly",
11959            "fatal: 'origin' does not appear to be a git repository\n\
11960             fatal: Could not read from remote repository.",
11961        ];
11962        for message in about_the_push {
11963            assert_eq!(None, transient_push_failure(message), "{message}");
11964        }
11965    }
11966
11967    /// A push driven by a script of answers, recording what the retry did.
11968    fn scripted_push(
11969        answers: Vec<std::result::Result<(), crate::error::SparError>>,
11970        landed: bool,
11971        interrupted: bool,
11972    ) -> (Result<()>, usize, usize, Vec<u64>) {
11973        let mut answers = answers.into_iter();
11974        let mut pushes = 0;
11975        let mut checks = 0;
11976        let mut waited = Vec::new();
11977        let result = retry_transient_push(
11978            "origin/issue-7",
11979            || {
11980                pushes += 1;
11981                answers.next().expect("pushed more often than scripted")
11982            },
11983            || {
11984                checks += 1;
11985                landed
11986            },
11987            &PUSH_RETRY_WAITS,
11988            |wait| {
11989                waited.push(wait.as_secs());
11990                interrupted
11991            },
11992        );
11993        (result, pushes, checks, waited)
11994    }
11995
11996    fn github_fault() -> std::result::Result<(), crate::error::SparError> {
11997        Err(crate::error::SparError::new(COMMIT_REFS_RULES))
11998    }
11999
12000    #[test]
12001    fn a_push_the_remote_failed_on_its_side_is_tried_again() {
12002        let (result, pushes, checks, waited) =
12003            scripted_push(vec![github_fault(), Ok(())], false, false);
12004
12005        assert!(result.is_ok(), "{result:?}");
12006        assert_eq!(2, pushes);
12007        assert_eq!(1, checks, "the remote is reread before pushing again");
12008        assert_eq!(vec![10], waited);
12009    }
12010
12011    #[test]
12012    fn a_remote_that_keeps_failing_is_reported_after_the_last_try() {
12013        let (result, pushes, checks, waited) = scripted_push(
12014            vec![github_fault(), github_fault(), github_fault()],
12015            false,
12016            false,
12017        );
12018
12019        let err = result.unwrap_err().to_string();
12020        assert!(err.contains("commit_refs_rules"), "{err}");
12021        assert_eq!(3, pushes);
12022        assert_eq!(3, checks, "the last failure is reread too");
12023        assert_eq!(vec![10, 30], waited);
12024    }
12025
12026    #[test]
12027    fn a_push_that_landed_despite_the_failure_is_not_pushed_again() {
12028        let (result, pushes, _, waited) = scripted_push(vec![github_fault()], true, false);
12029
12030        assert!(result.is_ok(), "{result:?}");
12031        assert_eq!(1, pushes);
12032        assert!(waited.is_empty(), "{waited:?}");
12033    }
12034
12035    #[test]
12036    fn a_push_refused_for_itself_is_not_tried_again() {
12037        let stale = Err(crate::error::SparError::new(
12038            " ! [rejected]        HEAD -> issue-7 (stale info)",
12039        ));
12040        let timed_out = Err(crate::error::SparError::timed_out(
12041            "fatal: the remote end hung up unexpectedly",
12042        ));
12043        for answer in [stale, timed_out] {
12044            let (result, pushes, checks, waited) = scripted_push(vec![answer], false, false);
12045
12046            assert!(result.is_err());
12047            assert_eq!((1, 0), (pushes, checks));
12048            assert!(waited.is_empty(), "{waited:?}");
12049        }
12050    }
12051
12052    #[test]
12053    fn a_stop_while_waiting_to_push_again_ends_the_retries() {
12054        let (result, pushes, _, waited) = scripted_push(vec![github_fault()], false, true);
12055
12056        assert!(result.is_err());
12057        assert_eq!(1, pushes);
12058        assert_eq!(vec![10], waited);
12059    }
12060
12061    /// The access advice is for a push the remote refused, and this one it
12062    /// failed to take.
12063    #[test]
12064    fn a_push_the_remote_kept_failing_says_to_try_again_later() {
12065        let err = push_error(
12066            &PushTarget::Origin,
12067            "issue-7",
12068            &crate::error::SparError::new(COMMIT_REFS_RULES),
12069        )
12070        .to_string();
12071
12072        assert!(
12073            err.starts_with(
12074                "could not push to origin/issue-7. [remote rejected] HEAD -> issue-7 (fatal \
12075                 error in commit_refs_rules)\nThe remote failed on its own side"
12076            ),
12077            "{err}"
12078        );
12079        assert!(err.ends_with("Try again later."), "{err}");
12080        assert!(!err.contains("Check push access"), "{err}");
12081    }
12082
12083    #[cfg(unix)]
12084    #[test]
12085    fn a_branch_is_known_to_have_landed_only_at_this_head() {
12086        let (_fixture, repo, work, _origin, fork) = denied_fixture("landed", true);
12087        let fork = fork.to_str().unwrap();
12088
12089        assert!(!repo.remote_branch_is_head(&work, fork, "issue-7"));
12090
12091        test_git(&work, &["push", "-q", fork, "main:refs/heads/issue-7"]);
12092        assert!(
12093            !repo.remote_branch_is_head(&work, fork, "issue-7"),
12094            "a branch somewhere else is not this push"
12095        );
12096
12097        test_git(&work, &["push", "-qf", fork, "HEAD:refs/heads/issue-7"]);
12098        assert!(repo.remote_branch_is_head(&work, fork, "issue-7"));
12099    }
12100
12101    #[test]
12102    fn a_fork_is_reached_by_whatever_reaches_origin() {
12103        let cases = [
12104            (
12105                "git@github.com:example/project.git",
12106                Some("git@github.com:someone/project.git"),
12107            ),
12108            // No user: ~/.ssh/config supplies it.
12109            (
12110                "github.com:example/project.git",
12111                Some("github.com:someone/project.git"),
12112            ),
12113            // A host alias, how one machine pushes as two accounts.
12114            (
12115                "git@github-work:example/project.git",
12116                Some("git@github-work:someone/project.git"),
12117            ),
12118            // SSH over 443, where 22 is blocked.
12119            (
12120                "ssh://git@ssh.github.com:443/example/project.git",
12121                Some("ssh://git@ssh.github.com:443/someone/project.git"),
12122            ),
12123            (
12124                "https://github.com/example/project",
12125                Some("https://github.com/someone/project"),
12126            ),
12127            (
12128                "https://ghe.example.com/scm/example/project.git/",
12129                Some("https://ghe.example.com/scm/someone/project.git"),
12130            ),
12131            (
12132                "git@host:/srv/example/project.git",
12133                Some("git@host:/srv/someone/project.git"),
12134            ),
12135            ("/srv/git/project.git", None),
12136            ("https://github.com/project", None),
12137            ("", None),
12138        ];
12139        for (origin, want) in cases {
12140            assert_eq!(
12141                want.map(str::to_string),
12142                fork_url(origin, "someone/project"),
12143                "{origin}"
12144            );
12145        }
12146    }
12147
12148    #[test]
12149    fn a_fork_found_or_made_is_reached_the_way_origin_is() {
12150        let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
12151            "ssh_url":"git@github.com:someone/project.git",
12152            "clone_url":"https://github.com/someone/project.git","fork":true}"#;
12153        let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
12154            "sshUrl":"git@github.com:someone/project.git",
12155            "url":"https://github.com/someone/project"}]"#;
12156
12157        let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
12158        assert_eq!("git@github-work:someone/project.git", fork.url);
12159        assert_eq!("someone", fork.owner);
12160        assert_eq!("someone/project", fork.full_name);
12161        let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
12162            .unwrap()
12163            .unwrap();
12164        assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
12165        assert_eq!(
12166            None,
12167            fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
12168        );
12169
12170        // An origin with no owner/repo path falls back to GitHub's URL, the
12171        // SSH one when origin looks like SSH.
12172        let fork = fork_from_api(made, "https://github.com/project").unwrap();
12173        assert_eq!("https://github.com/someone/project.git", fork.url);
12174        let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
12175        assert_eq!("git@github.com:someone/project.git", fork.url);
12176
12177        assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
12178        assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
12179    }
12180
12181    #[test]
12182    fn only_the_viewers_own_fork_can_be_pushed_to() {
12183        let pr = |owner: &str, name: &str| PrView {
12184            number: 9,
12185            url: String::new(),
12186            title: String::new(),
12187            body: String::new(),
12188            head_ref_name: "issue-7".into(),
12189            base_ref_name: "main".into(),
12190            state: "OPEN".into(),
12191            closing_issues_references: Vec::new(),
12192            is_cross_repository: true,
12193            head_repository_owner: Some(crate::model::Author {
12194                login: owner.into(),
12195            }),
12196            head_repository: Some(crate::model::HeadRepository { name: name.into() }),
12197        };
12198        let origin = "git@github.com:example/project.git";
12199
12200        assert_eq!(
12201            Some(PushTarget::Fork(Fork {
12202                owner: "Someone".into(),
12203                full_name: "Someone/project".into(),
12204                url: "git@github.com:Someone/project.git".into(),
12205            })),
12206            own_fork_target(&pr("Someone", "project"), "someone", origin)
12207        );
12208        assert_eq!(
12209            None,
12210            own_fork_target(&pr("other", "project"), "someone", origin)
12211        );
12212        assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
12213        assert_eq!(
12214            None,
12215            own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
12216        );
12217        let mut unnamed = pr("someone", "project");
12218        unnamed.head_repository_owner = None;
12219        assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
12220    }
12221
12222    /// `spar resume` and `spar checkin` on a pull request from the user's own
12223    /// fork check its branch out from the fork and push back there.
12224    #[cfg(unix)]
12225    #[test]
12226    fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
12227        let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
12228        test_git(
12229            &work,
12230            &[
12231                "push",
12232                "-q",
12233                fork.to_str().unwrap(),
12234                "issue-7:refs/heads/issue-7",
12235            ],
12236        );
12237        let published = test_git(&work, &["rev-parse", "issue-7"]);
12238        test_git(&work, &["checkout", "-q", "main"]);
12239        let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
12240        let pr = PrView {
12241            number: 31,
12242            url: String::new(),
12243            title: String::new(),
12244            body: String::new(),
12245            head_ref_name: "issue-7".into(),
12246            base_ref_name: "main".into(),
12247            state: "OPEN".into(),
12248            closing_issues_references: Vec::new(),
12249            is_cross_repository: true,
12250            head_repository_owner: None,
12251            head_repository: None,
12252        };
12253
12254        let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
12255
12256        assert_eq!("issue-7", branch);
12257        assert_eq!(
12258            published.trim(),
12259            test_git(&path, &["rev-parse", "HEAD"]).trim()
12260        );
12261        std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
12262        test_git(&path, &["commit", "-qam", "Answer a comment"]);
12263        repo.push_to(&path, &target, &branch, Some(published.trim()))
12264            .unwrap();
12265        assert_eq!(
12266            test_git(&path, &["rev-parse", "HEAD"]).trim(),
12267            test_git(&fork, &["rev-parse", "issue-7"]).trim()
12268        );
12269        assert!(test_git(&origin, &["branch", "--list", "issue-7"])
12270            .trim()
12271            .is_empty());
12272    }
12273
12274    #[cfg(unix)]
12275    #[test]
12276    fn with_forking_off_a_refused_push_says_why_and_how() {
12277        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
12278
12279        let err = repo
12280            .publish_new_branch(&work, "issue-7", "main")
12281            .unwrap_err()
12282            .to_string();
12283
12284        assert!(
12285            err.contains("Permission to example/project.git denied to someone."),
12286            "{err}"
12287        );
12288        assert!(err.contains("fork_when_denied = true"), "{err}");
12289        assert!(test_git(&fork, &["branch", "--list", "issue-7"])
12290            .trim()
12291            .is_empty());
12292        assert_eq!(
12293            WriteSummary {
12294                attempted: 1,
12295                failed: 1
12296            },
12297            repo.write_summary()
12298        );
12299    }
12300}
12301
12302#[cfg(test)]
12303mod comment_page_tests {
12304    use super::*;
12305
12306    #[test]
12307    fn a_single_merged_array_is_read() {
12308        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
12309        assert_eq!(2, pages.len());
12310        assert_eq!(Some(2), pages[1]["id"].as_i64());
12311    }
12312
12313    #[test]
12314    fn concatenated_pages_from_an_older_gh_are_read_too() {
12315        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
12316        assert_eq!(2, pages.len());
12317    }
12318
12319    /// A comment body containing a bracket pair used to split the payload into
12320    /// two invalid halves, so no state comment was found and a resume silently
12321    /// started from round one.
12322    #[test]
12323    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
12324        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
12325        let pages = parse_comment_pages(text);
12326        assert_eq!(2, pages.len(), "{pages:?}");
12327        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
12328    }
12329
12330    #[test]
12331    fn empty_output_is_no_comments_not_a_panic() {
12332        assert!(parse_comment_pages("").is_empty());
12333        assert!(parse_comment_pages("   ").is_empty());
12334        assert!(parse_comment_pages("[]").is_empty());
12335    }
12336
12337    #[test]
12338    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
12339        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
12340    }
12341
12342    #[test]
12343    fn a_write_postcheck_rejects_truncated_comment_pages() {
12344        let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
12345        assert!(
12346            error.to_string().contains("unexpected comment pages"),
12347            "{error}"
12348        );
12349    }
12350
12351    #[test]
12352    fn a_write_postcheck_rejects_empty_or_non_array_output() {
12353        assert!(try_parse_comment_pages("").is_err());
12354        assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
12355        assert!(try_parse_comment_pages("[]").is_ok());
12356    }
12357
12358    #[test]
12359    fn state_is_found_in_the_last_matching_comment() {
12360        let payload = |round: u32| {
12361            format!(
12362                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
12363            )
12364        };
12365        let text = serde_json::to_string(&serde_json::json!([
12366            {"id": 1, "body": payload(1)},
12367            {"id": 2, "body": "looks good to me"},
12368            {"id": 3, "body": payload(5)},
12369        ]))
12370        .unwrap();
12371        let pages = parse_comment_pages(&text);
12372        let last = pages
12373            .iter()
12374            .rev()
12375            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
12376            .unwrap();
12377        assert_eq!(5, last.round);
12378    }
12379}
12380
12381#[cfg(test)]
12382mod linked_pr_tests {
12383    use super::*;
12384
12385    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
12386    /// It carries an id and a whole repository object per linked issue, and a
12387    /// parser that chokes on those reports "no pull request", which is the one
12388    /// answer that makes spar implement over the top of somebody's work.
12389    const REAL_PAYLOAD: &str = r#"[
12390      {"number":14252,"title":"fix: reject leading-dash branch names",
12391       "url":"https://github.com/cli/cli/pull/14252",
12392       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
12393         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
12394         "url":"https://github.com/cli/cli/issues/14238"}]},
12395      {"number":14217,"title":"another change",
12396       "url":"https://github.com/cli/cli/pull/14217",
12397       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
12398         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
12399         "url":"https://github.com/cli/cli/issues/9761"}]},
12400      {"number":14200,"title":"unlinked work",
12401       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
12402    ]"#;
12403
12404    #[test]
12405    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
12406        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
12407        assert_eq!(14252, pr.number);
12408        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
12409    }
12410
12411    #[test]
12412    fn the_right_pr_is_picked_out_of_several() {
12413        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
12414    }
12415
12416    #[test]
12417    fn an_issue_nobody_is_working_on_finds_nothing() {
12418        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
12419    }
12420
12421    #[test]
12422    fn an_unlinked_pr_is_never_matched() {
12423        // 14200 closes nothing, so no issue number should ever return it.
12424        for issue in [14200, 0, 1] {
12425            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
12426                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
12427            }
12428        }
12429    }
12430
12431    #[test]
12432    fn empty_or_broken_output_is_none_rather_than_a_panic() {
12433        assert!(find_linked_pr("", 1).is_none());
12434        assert!(find_linked_pr("[]", 1).is_none());
12435        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
12436        assert!(find_linked_pr("[{\"number\":", 1).is_none());
12437    }
12438
12439    /// "gh could not answer" and "nobody is working on it" are different
12440    /// answers, and only one of them means implement from scratch.
12441    ///
12442    /// The parse used to fold both into `None`, so an authentication failure or
12443    /// a rate limit read as an empty list and spar opened a second pull request
12444    /// for an issue somebody already had work open on.
12445    #[test]
12446    fn an_unreadable_list_is_not_an_empty_list() {
12447        assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
12448        assert!(linked_pr_rows("[{\"number\":").is_none());
12449        assert!(linked_pr_rows("").is_none());
12450
12451        let empty = linked_pr_rows("[]").expect("an empty list is an answer");
12452        assert!(empty.is_empty());
12453        assert!(pick_linked_pr(&empty, 14238).is_none());
12454
12455        let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
12456        assert_eq!(3, listed.len());
12457        assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
12458    }
12459
12460    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
12461    #[test]
12462    fn pr_view_reads_the_cross_repository_flag() {
12463        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
12464                       "baseRefName":"main","state":"OPEN",
12465                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
12466        let pr: PrView = serde_json::from_str(json).unwrap();
12467        assert!(pr.is_cross_repository);
12468        assert!(pr.is_open());
12469
12470        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
12471        assert!(
12472            !serde_json::from_str::<PrView>(&same_repo)
12473                .unwrap()
12474                .is_cross_repository
12475        );
12476    }
12477}
12478
12479#[cfg(test)]
12480mod min_number_tests {
12481    /// The floor is applied before the cap, which is the order that matters.
12482    /// spar takes the *lowest* numbered open items, so a repository with a tail
12483    /// of old issues would otherwise spend its whole run in the tail: the cap
12484    /// would be filled by the oldest items and the floor would never be
12485    /// reached. Filtering first is what makes the setting do anything.
12486    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
12487        let mut numbers: Vec<i64> = open.to_vec();
12488        numbers.sort_unstable();
12489        if min_number > 0 {
12490            numbers.retain(|n| *n >= min_number);
12491        }
12492        numbers.truncate(limit);
12493        numbers
12494    }
12495
12496    #[test]
12497    fn the_floor_is_applied_before_the_cap_not_after() {
12498        let open = [12, 13, 14, 480, 481, 482];
12499        assert_eq!(vec![480, 481], pick(&open, 2, 480));
12500        // Capping first would have returned the two oldest and then filtered
12501        // them all away, leaving nothing.
12502        assert!(!pick(&open, 2, 480).is_empty());
12503    }
12504
12505    #[test]
12506    fn no_floor_keeps_the_old_behaviour() {
12507        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
12508    }
12509
12510    #[test]
12511    fn the_floor_is_inclusive() {
12512        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
12513    }
12514
12515    #[test]
12516    fn a_floor_above_everything_open_yields_nothing() {
12517        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
12518    }
12519}
12520
12521#[cfg(test)]
12522mod review_record_tests {
12523    use super::*;
12524    use serde_json::json;
12525
12526    #[test]
12527    fn prune_state_knows_which_files_belong_to_a_pull_request() {
12528        assert_eq!(Some(12), state_file_number("pr-12.json"));
12529        assert_eq!(Some(12), state_file_number("review-12.json"));
12530        assert_eq!(Some(12), state_file_number("checkin-12.json"));
12531        for shared in [
12532            "spend.json",
12533            "implementors.json",
12534            "contested.json",
12535            "listen.json",
12536            "pr-.json",
12537            "pr-12.json.tmp",
12538            "pr-x.json",
12539        ] {
12540            assert_eq!(None, state_file_number(shared), "{shared}");
12541        }
12542    }
12543
12544    #[test]
12545    fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
12546        let reviews = vec![
12547            json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
12548            json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
12549        ];
12550        assert!(has_viewer_review(&reviews, "me", &["the body"], "aaa"));
12551        assert!(!has_viewer_review(&reviews, "me", &["other"], "aaa"));
12552        assert!(!has_viewer_review(&reviews, "alice", &["the body"], "aaa"));
12553    }
12554
12555    /// A review with line comments goes up with a brief body, and the same
12556    /// review refused its lines goes up with the full one. A rerun after either
12557    /// landed would otherwise post a second review.
12558    #[test]
12559    fn a_review_that_landed_with_either_body_is_not_sent_again() {
12560        let brief = vec![
12561            json!({"user": {"login": "me"}, "body": "brief", "commit_id": "aaa", "state": "COMMENTED"}),
12562        ];
12563        let full = vec![
12564            json!({"user": {"login": "me"}, "body": "full", "commit_id": "aaa", "state": "CHANGES_REQUESTED"}),
12565        ];
12566        for landed in [&brief, &full] {
12567            assert!(has_viewer_review(landed, "me", &["brief", "full"], "aaa"));
12568            assert!(has_viewer_review(landed, "me", &["full", "brief"], "aaa"));
12569            assert!(!has_viewer_review(landed, "me", &["brief", "full"], "bbb"));
12570        }
12571        assert!(!has_viewer_review(&brief, "me", &["full"], "aaa"));
12572        assert!(!has_viewer_review(&full, "me", &[], "aaa"));
12573    }
12574
12575    /// A clean review says the same thing on every commit. Taking the approval
12576    /// of an older commit for this one left the new head unapproved while the
12577    /// record said it was.
12578    #[test]
12579    fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
12580        let older = vec![
12581            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
12582        ];
12583        assert!(!has_viewer_review(&older, "me", &["the body"], "bbb"));
12584        let dismissed = vec![
12585            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
12586        ];
12587        assert!(!has_viewer_review(&dismissed, "me", &["the body"], "bbb"));
12588    }
12589
12590    #[test]
12591    fn a_response_splits_into_status_headers_and_body() {
12592        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";
12593        let resp = parse_api_response(text).expect("a response");
12594        assert_eq!(200, resp.status);
12595        assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
12596        assert_eq!(
12597            Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
12598            resp.headers.get("last-modified")
12599        );
12600        assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
12601
12602        let not_modified =
12603            parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
12604        assert_eq!(304, not_modified.status);
12605        assert!(not_modified.body.is_empty());
12606
12607        assert_eq!(None, parse_api_response(""));
12608        assert_eq!(None, parse_api_response("gh: command not found\n"));
12609    }
12610
12611    #[test]
12612    fn review_requests_are_read_off_the_timeline_with_who_and_when() {
12613        let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
12614            {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
12615            {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
12616            {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
12617            {}
12618        ]}}}}})
12619        .to_string();
12620        let made = parse_review_requests(&text).expect("requests");
12621        assert_eq!(2, made.len());
12622        assert_eq!("2026-10-20T10:00:00Z", made[0].at);
12623        assert_eq!("viewer", made[0].who.login);
12624        assert!(made[0].who.slug.is_empty());
12625        assert_eq!("2026-10-20T11:00:00Z", made[1].at);
12626        assert!(made[1].who.login.is_empty());
12627        assert_eq!("example/ours", made[1].who.slug);
12628
12629        let none =
12630            json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
12631        assert_eq!(
12632            Some(0),
12633            parse_review_requests(&none.to_string()).map(|m| m.len())
12634        );
12635        assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
12636        assert!(parse_review_requests("gh: not json").is_none());
12637    }
12638
12639    #[test]
12640    fn assigned_issues_are_read_with_who_holds_them_and_when_each_was_assigned() {
12641        let text = json!({"data": {"repository": {"issues": {"nodes": [
12642            {
12643                "number": 1144,
12644                "title": "Send max",
12645                "assignees": {"nodes": [{"login": "viewer"}]},
12646                "timelineItems": {"nodes": [
12647                    {"createdAt": "2026-08-10T10:00:00Z", "assignee": {"login": "viewer"}},
12648                    {"createdAt": "2026-11-03T12:36:22Z", "assignee": {"login": "a-bot"}},
12649                    {"createdAt": "2026-11-03T12:40:00Z", "assignee": null},
12650                    {"assignee": {"login": "viewer"}},
12651                    {}
12652                ]}
12653            },
12654            {"title": "no number"},
12655            {"number": 7}
12656        ]}}}})
12657        .to_string();
12658        let issues = parse_assigned_issues(&text).expect("issues");
12659        assert_eq!(2, issues.len());
12660        assert_eq!(1144, issues[0].number);
12661        assert_eq!("Send max", issues[0].title);
12662        assert_eq!(vec!["viewer".to_string()], issues[0].assignees);
12663        assert_eq!(
12664            vec![
12665                ("2026-08-10T10:00:00Z".to_string(), "viewer".to_string()),
12666                ("2026-11-03T12:36:22Z".to_string(), "a-bot".to_string()),
12667            ],
12668            issues[0].assigned
12669        );
12670        assert_eq!(7, issues[1].number);
12671        assert!(issues[1].assignees.is_empty() && issues[1].assigned.is_empty());
12672
12673        let none = json!({"data": {"repository": {"issues": {"nodes": []}}}});
12674        assert_eq!(
12675            Some(0),
12676            parse_assigned_issues(&none.to_string()).map(|i| i.len())
12677        );
12678        assert!(parse_assigned_issues(r#"{"errors": [{"message": "no"}]}"#).is_none());
12679        assert!(parse_assigned_issues("gh: not json").is_none());
12680    }
12681
12682    #[test]
12683    fn one_issues_assignments_are_read_the_same_way() {
12684        let text = json!({"data": {"repository": {"issue": {
12685            "number": 42,
12686            "title": "t",
12687            "assignees": {"nodes": [{"login": "Viewer"}]},
12688            "timelineItems": {"nodes": [
12689                {"createdAt": "2026-11-03T12:36:22Z", "assignee": {"login": "Viewer"}}
12690            ]}
12691        }}}})
12692        .to_string();
12693        let issue = parse_issue_assignments(&text).expect("an issue");
12694        assert_eq!(42, issue.number);
12695        assert_eq!(
12696            Some("2026-11-03T12:36:22Z".to_string()),
12697            issue.newest_for("viewer")
12698        );
12699        assert!(parse_issue_assignments(r#"{"data": {"repository": {"issue": null}}}"#).is_none());
12700        assert!(parse_issue_assignments("gh: not json").is_none());
12701    }
12702
12703    #[test]
12704    fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
12705        let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
12706        let error = crate::error::SparError::call_failed("gh: boom");
12707        assert!(
12708            reconcile_review_post(5, &["the body"], "me", "aaa", error.clone(), Ok(landed)).is_ok()
12709        );
12710
12711        let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
12712        let err = reconcile_review_post(5, &["the body"], "me", "aaa", error.clone(), Ok(missing))
12713            .unwrap_err();
12714        assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
12715
12716        let unreadable = Err(crate::error::SparError::new("no network"));
12717        let err =
12718            reconcile_review_post(5, &["the body"], "me", "aaa", error, unreadable).unwrap_err();
12719        assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
12720        assert!(err.to_string().contains("could not be verified"), "{err}");
12721    }
12722
12723    #[test]
12724    fn a_review_found_standing_links_to_itself() {
12725        let url = "https://github.com/o/r/pull/5#pullrequestreview-7";
12726        let reviews = vec![
12727            json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa", "html_url": "https://github.com/o/r/pull/5#pullrequestreview-6"}),
12728            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "html_url": url}),
12729        ];
12730        let found = viewer_review(&reviews, "me", &["the body"], "aaa").map(html_url);
12731        assert_eq!(Some(Some(url.to_string())), found);
12732        let error = crate::error::SparError::call_failed("gh: boom");
12733        let reconciled =
12734            reconcile_review_post(5, &["the body"], "me", "aaa", error.clone(), Ok(reviews));
12735        assert_eq!(Some(url.to_string()), reconciled.unwrap());
12736
12737        let unlinked =
12738            vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
12739        let reconciled = reconcile_review_post(5, &["the body"], "me", "aaa", error, Ok(unlinked));
12740        assert_eq!(None, reconciled.unwrap(), "no address is still a review");
12741    }
12742
12743    #[test]
12744    fn a_comment_found_standing_links_to_itself() {
12745        let url = "https://github.com/o/r/pull/34#issuecomment-9";
12746        let comments = vec![
12747            json!({"body": "other", "html_url": "https://github.com/o/r/pull/34#issuecomment-8"}),
12748            json!({"body": "the summary", "html_url": url}),
12749        ];
12750        assert_eq!(
12751            Some(url.to_string()),
12752            exact_comment(&comments, "the summary").and_then(html_url)
12753        );
12754        let reconciled = reconcile_comment_post(
12755            34,
12756            "the summary",
12757            crate::error::SparError::new("connection lost"),
12758            Ok(comments),
12759        );
12760        assert_eq!(Some(url.to_string()), reconciled.unwrap());
12761        assert_eq!(None, html_url(&json!({"body": "x", "html_url": ""})));
12762    }
12763
12764    #[test]
12765    fn the_comment_gh_made_is_the_address_it_printed() {
12766        let url = "https://github.com/o/r/pull/34#issuecomment-9";
12767        assert_eq!(Some(url.to_string()), printed_url(&format!("{url}\n")));
12768        assert_eq!(
12769            Some(url.to_string()),
12770            printed_url(&format!("Warning: 1 uncommitted change\n  {url}  \ndone\n"))
12771        );
12772        assert_eq!(None, printed_url(""));
12773        assert_eq!(None, printed_url("commented\nsee http://example.test\n"));
12774    }
12775
12776    #[test]
12777    fn the_review_github_made_is_the_address_in_its_answer() {
12778        let url = "https://github.com/o/r/pull/5#pullrequestreview-7";
12779        let answer = json!({"id": 7, "state": "APPROVED", "html_url": url}).to_string();
12780        assert_eq!(Some(url.to_string()), review_url(&answer));
12781        assert_eq!(None, review_url(r#"{"id": 7, "state": "APPROVED"}"#));
12782        assert_eq!(None, review_url(""));
12783        assert_eq!(None, review_url("not json"));
12784    }
12785
12786    #[test]
12787    fn a_review_with_line_comments_names_the_new_side_and_a_range_only_when_it_has_one() {
12788        let comments = vec![
12789            InlineComment {
12790                path: "src/a.rs".into(),
12791                line: 12,
12792                start_line: None,
12793                body: "one".into(),
12794            },
12795            InlineComment {
12796                path: "src/b.rs".into(),
12797                line: 9,
12798                start_line: Some(4),
12799                body: "two".into(),
12800            },
12801        ];
12802        assert_eq!(
12803            json!({
12804                "commit_id": "abc",
12805                "event": "COMMENT",
12806                "body": "the body",
12807                "comments": [
12808                    {"path": "src/a.rs", "line": 12, "side": "RIGHT", "body": "one"},
12809                    {"path": "src/b.rs", "line": 9, "side": "RIGHT", "start_line": 4,
12810                     "start_side": "RIGHT", "body": "two"},
12811                ],
12812            }),
12813            review_payload("abc", "COMMENT", "the body", &comments)
12814        );
12815    }
12816
12817    #[test]
12818    fn the_new_side_of_each_hunk_is_read_and_its_lines_are_never_taken_for_headers() {
12819        let diff = r#"diff --git a/src/a.rs b/src/a.rs
12820index 1111111..2222222 100644
12821--- a/src/a.rs
12822+++ b/src/a.rs
12823@@ -1,3 +1,4 @@ fn x
12824 one
12825+++ an added line that looks like a header
12826 two
12827 three
12828@@ -20 +21,2 @@
12829-old
12830+new
12831+more
12832@@ -30,1 +32,0 @@
12833-gone
12834diff --git a/gone.rs b/gone.rs
12835deleted file mode 100644
12836--- a/gone.rs
12837+++ /dev/null
12838@@ -1,2 +0,0 @@
12839-a
12840-b
12841diff --git a/old.rs b/renamed.rs
12842similarity index 90%
12843rename from old.rs
12844rename to renamed.rs
12845--- a/old.rs
12846+++ b/renamed.rs
12847@@ -5,0 +6,1 @@
12848+inserted
12849diff --git a/with space.rs b/with space.rs
12850--- a/with space.rs	
12851+++ b/with space.rs	
12852@@ -2 +2 @@
12853-x
12854+y
12855diff --git "a/sp\tace.rs" "b/sp\tace.rs"
12856--- "a/sp\tace.rs"
12857+++ "b/sp\tace.rs"
12858@@ -1 +1 @@
12859-x
12860+y
12861"#;
12862        let hunks = new_side_hunks(diff);
12863        assert_eq!(
12864            Hunks::from([
12865                ("renamed.rs".to_string(), vec![(6, 6)]),
12866                ("src/a.rs".to_string(), vec![(1, 4), (21, 22)]),
12867                ("with space.rs".to_string(), vec![(2, 2)]),
12868            ]),
12869            hunks
12870        );
12871    }
12872
12873    #[test]
12874    fn only_a_refusal_is_final_enough_to_send_a_review_again() {
12875        let error = |text: &str| crate::error::SparError::new(text);
12876        assert!(refused(&error(
12877            "command failed (1): gh api\ngh: Validation Failed (HTTP 422)"
12878        )));
12879        assert!(!refused(&error("gh: Bad Gateway (HTTP 502)")));
12880        assert!(!refused(&error("gh: HTTP 504: Gateway Timeout")));
12881        assert!(!refused(&error("command timed out after 300s")));
12882    }
12883
12884    #[test]
12885    fn a_hunk_header_with_its_counts_left_out_counts_one() {
12886        assert_eq!(Some(((20, 1), (21, 2))), hunk_header("@@ -20 +21,2 @@"));
12887        assert_eq!(Some(((1, 3), (1, 1))), hunk_header("@@ -1,3 +1 @@ fn x"));
12888        assert_eq!(None, hunk_header("@@@ -1,3 -1,3 +1,4 @@@"));
12889        assert_eq!(None, hunk_header(" @@ -1 +1 @@"));
12890    }
12891}