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    fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
450        after
451            .files
452            .keys()
453            .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
454            .cloned()
455            .collect()
456    }
457
458    /// Whether anything moved here that is not recognized build or cache
459    /// output.
460    ///
461    /// A read-only call is held to its word by comparing the worktree before
462    /// and after. Verifying a finding usually means building the project and
463    /// running its tests, which rewrites that output, and holding a reviewer to
464    /// a byte-identical `dist/` threw away the review it had just done. What
465    /// the call was asked to judge is the tracked tree, and that is compared as
466    /// strictly as ever, along with every other untracked file.
467    pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
468        let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
469        paths.extend(after.files.keys());
470        paths.into_iter().any(|path| {
471            if self.files.get(path) == after.files.get(path)
472                && self.is_ignored(path) == after.is_ignored(path)
473            {
474                return false;
475            }
476            !self.generated_move(after, path)
477        })
478    }
479
480    /// What a read-only call left behind, split by whether it condemns the
481    /// answer.
482    ///
483    /// `changed_beyond_generated` answers this from a list of directory names,
484    /// which is a guess about where a build writes. The repository already
485    /// states where it writes, in its own ignore rules, and a review died over
486    /// the gap: a reviewer ran the project's binding generator to check a
487    /// finding, that wrote a lockfile beside its own manifest rather than under
488    /// `target/`, and both agents lost an hour of work to a path Git had been
489    /// told to disown.
490    ///
491    /// Forgiving only the creation of such a path was not enough, because the
492    /// call is not the only writer. A read-only call runs for minutes in the
493    /// primary checkout, which is a live desk: Finder rewrites `.DS_Store`, an
494    /// editor rewrites its own state, and a triage that asks two agents at once
495    /// compares both of them against that same directory. One ambient write
496    /// discarded two good answers and ended the run with nothing scheduled. So
497    /// a path the project ignores is out of scope however it moved, and what
498    /// remains fatal is what the call was actually asked about: the tracked
499    /// tree, and any ordinary untracked file, because a reviewer was told to
500    /// keep its scratch out of the worktree.
501    ///
502    /// Nothing is silent. A tolerated path that is not recognized build output
503    /// is named by the caller, so an agent that rewrote a person's `.env` is
504    /// reported rather than hidden. That is the trade this makes: the answer of
505    /// a call that never commits is worth more than the guarantee, and the
506    /// warning carries what the guarantee used to.
507    pub(crate) fn read_only_leavings(&self, after: &Self) -> ReadOnlyLeavings {
508        let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
509        paths.extend(after.files.keys());
510        let mut leavings = ReadOnlyLeavings::default();
511        for path in paths {
512            if self.files.get(path) == after.files.get(path)
513                && self.is_ignored(path) == after.is_ignored(path)
514            {
515                continue;
516            }
517            if !self.ignored_file_move(after, path) {
518                leavings.condemning.push(path.clone());
519            } else if !is_generated_artifact(path) {
520                leavings.tolerated.push(path.clone());
521            }
522        }
523        leavings
524    }
525
526    /// Whether what happened at `path` between these two states is a build or
527    /// cache directory doing its job, rather than a change a person would miss.
528    ///
529    /// Removal counts, on either side. A compiler that keeps an incremental
530    /// cache does not only add and rewrite: it finalizes a new session
531    /// directory and deletes the one it superseded, lock file and all. A rule
532    /// that forgave creation and rewriting but not removal therefore passed the
533    /// first build in a fresh worktree and failed the second one, which is the
534    /// build a reviewer runs to check a finding. Two of these rules disagreeing
535    /// about the same directory is what cost the run: the read-only check
536    /// forgave the deleted session and the commit-side check called it
537    /// unrepresentable, so the work was already on a pull request when the run
538    /// ended. There is one rule now, and this is it.
539    fn generated_move(&self, after: &Self, path: &Path) -> bool {
540        is_generated_artifact(path) && self.disposable_move(after, path)
541    }
542
543    /// Successful edits and read-only calls share an ignore policy. Spar's
544    /// resident worktrees belong to other runs, not to this call's output.
545    /// Cleanup separately preserves ignored data, including dependency clones.
546    fn ignored_file_move(&self, after: &Self, path: &Path) -> bool {
547        if path.starts_with(WORKTREE_DIR)
548            && self.files.get(path).is_some_and(|file| file.kind == 3)
549            && self.files.get(path) != after.files.get(path)
550        {
551            return false;
552        }
553        self.disposable_move(after, path)
554    }
555
556    /// Whether neither state has anything at `path` worth keeping, so whatever
557    /// happened there took nothing with it.
558    fn disposable_move(&self, after: &Self, path: &Path) -> bool {
559        self.disposable(path) && after.disposable(path)
560    }
561
562    /// Whether this state has nothing at `path` worth keeping: either the path
563    /// is not there at all, or it is there as an ignored file.
564    fn disposable(&self, path: &Path) -> bool {
565        !self.files.contains_key(path) || self.is_ignored(path)
566    }
567}
568
569impl GitState {
570    /// What moved between two states, said so a person can act on it.
571    ///
572    /// The guards that compare these used to report only that the worktree had
573    /// changed, which left the one question worth answering, which file, to a
574    /// `git status` run by hand against a tree that had moved on since.
575    pub(crate) fn describe_difference(&self, after: &Self) -> Option<String> {
576        let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
577        prefixes.extend(after.repositories.keys());
578        let mut notes: Vec<String> = Vec::new();
579        let mut paths: Vec<PathBuf> = Vec::new();
580        for prefix in prefixes {
581            let label = describe_repository(prefix);
582            match (
583                self.repositories.get(prefix),
584                after.repositories.get(prefix),
585            ) {
586                (None, Some(_)) => notes.push(format!("a repository appeared at {label}")),
587                (Some(_), None) => notes.push(format!("the repository at {label} is gone")),
588                (Some(before), Some(current)) => {
589                    if before.head != current.head {
590                        notes.push(format!("HEAD moved in {label}"));
591                    }
592                    if before.unsafe_index_flags != current.unsafe_index_flags {
593                        notes.push(format!("index flags changed in {label}"));
594                    }
595                    paths.extend(
596                        differing_keys(&before.gitlinks, &current.gitlinks)
597                            .chain(differing_keys(&before.tracked, &current.tracked))
598                            .map(|path| prefix.join(path)),
599                    );
600                }
601                (None, None) => {}
602            }
603        }
604        if !paths.is_empty() {
605            paths.sort();
606            paths.dedup();
607            notes.push(format!("tracked file(s) changed: {}", list_paths(&paths)));
608        }
609        (!notes.is_empty()).then(|| notes.join("; "))
610    }
611
612    /// The tracked half of what a read-only call left behind, with a checkout
613    /// that moved under the call reported rather than held against it.
614    pub(crate) fn read_only_difference(&self, after: &Self, cwd: &Path) -> Option<String> {
615        let difference = self.describe_difference(after)?;
616        let Some(moved) = self.moved_wholesale(after) else {
617            return Some(difference);
618        };
619        // Once, however many calls were reading when it happened: two triage
620        // agents share the checkout and would otherwise both report the move.
621        if said_once("moved", cwd, &moved) {
622            logwarn!(
623                "the checkout at {} moved while a read-only call was reading it: {moved}. The \
624                 answer was kept, and it describes the tree as it stood before the move.",
625                cwd.display()
626            );
627        }
628        None
629    }
630
631    /// Whether every difference between these two states is the checkout moving
632    /// as a whole rather than the call writing into it.
633    ///
634    /// The primary checkout is a live desk and a read-only call reads it for
635    /// minutes. A person or their editor pulls, the branch fast forwards, and
636    /// every file that arrived with it now differs from the snapshot taken
637    /// before the call. Holding the call to that snapshot threw away two good
638    /// triage answers and ended a run with nothing scheduled, over a change the
639    /// call had no part in.
640    ///
641    /// The index tells the two apart. A call that wrote leaves a path
642    /// disagreeing with the index, staged or unstaged, and leaves HEAD where it
643    /// was. A checkout that moved rewrote the index and the worktree together,
644    /// so every path it touched agrees with the index, and it moved HEAD to say
645    /// so. Anything else, a tracked file that changed while HEAD stood still
646    /// most of all, is the call's to answer for.
647    fn moved_wholesale(&self, after: &Self) -> Option<String> {
648        let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
649        prefixes.extend(after.repositories.keys());
650        let mut notes: Vec<String> = Vec::new();
651        for prefix in prefixes {
652            // A repository that appeared or vanished is not a checkout moving.
653            let before = self.repositories.get(prefix)?;
654            let current = after.repositories.get(prefix)?;
655            if before.unsafe_index_flags != current.unsafe_index_flags
656                || before.gitlinks != current.gitlinks
657            {
658                return None;
659            }
660            let moved: Vec<&PathBuf> = differing_keys(&before.tracked, &current.tracked).collect();
661            if before.head == current.head {
662                if moved.is_empty() {
663                    continue;
664                }
665                return None;
666            }
667            if !moved.iter().all(|path| {
668                checked_out(before.tracked.get(*path)) && checked_out(current.tracked.get(*path))
669            }) {
670                return None;
671            }
672            let label = describe_repository(prefix);
673            notes.push(match moved.len() {
674                0 => format!("HEAD moved in {label}"),
675                count => format!("HEAD moved in {label}, carrying {count} tracked file(s)"),
676            });
677        }
678        (!notes.is_empty()).then(|| notes.join("; "))
679    }
680}
681
682/// Whether a tracked path holds exactly what the index says it holds, so
683/// nothing there is staged or left unstaged.
684///
685/// A path the index no longer lists counts as checked out: only an index
686/// operation drops one, and a file left in the worktree after that is an
687/// ordinary untracked file, which the untracked half of the same check has.
688fn checked_out(entry: Option<&TrackedEntry>) -> bool {
689    entry.is_none_or(|entry| {
690        entry
691            .worktree
692            .as_ref()
693            .is_some_and(|file| file.raw_oid == entry.index_oid && file.mode == entry.index_mode)
694    })
695}
696
697/// Say what a call left among paths the project ignores, once per run for a
698/// given set of them.
699///
700/// The project's own ignore rules put these outside what the call was asked
701/// about, so this is a note about the desk the call ran on rather than a
702/// finding about the call. Said as a warning on every call, it became the
703/// loudest thing in the log of every run in a checkout with an editor open in
704/// it: `.DS_Store` twice per triage, and again for each review. It still names
705/// the paths, so an agent that rewrote a person's `.env` is reported rather
706/// than hidden.
707pub(crate) fn note_ignored_leavings(cwd: &Path, what: &str, paths: &[PathBuf]) {
708    if paths.is_empty() {
709        return;
710    }
711    let named = list_paths(paths);
712    if !said_once("ignored", cwd, &named) {
713        return;
714    }
715    logdim!(
716        "a read-only {what} left {} ignored file(s) changed in {}: {named}. It was kept; the \
717         project ignores these paths.",
718        paths.len(),
719        cwd.display()
720    );
721}
722
723/// Whether this run has yet to say this about this directory.
724///
725/// A run reads the same checkout dozens of times, and the ambient facts about
726/// it, an editor's own files moving and a branch advancing under the reader,
727/// are true for every one of those calls. Said once they are context; said
728/// each time they are the log.
729fn said_once(topic: &str, cwd: &Path, detail: &str) -> bool {
730    static SAID: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
731    SAID.lock()
732        .unwrap_or_else(std::sync::PoisonError::into_inner)
733        .insert(format!("{topic}\0{}\0{detail}", cwd.display()))
734}
735
736/// Name a repository inside a state for a report. The root carries an empty
737/// prefix, and calling that "" helps nobody.
738fn describe_repository(prefix: &Path) -> String {
739    if prefix.as_os_str().is_empty() {
740        "the worktree".to_string()
741    } else {
742        format!("the submodule at {:?}", prefix.as_os_str())
743    }
744}
745
746/// The keys whose values differ between two maps, present on either side.
747fn differing_keys<'a, V: PartialEq>(
748    before: &'a BTreeMap<PathBuf, V>,
749    after: &'a BTreeMap<PathBuf, V>,
750) -> impl Iterator<Item = &'a PathBuf> {
751    let mut keys: BTreeSet<&'a PathBuf> = before.keys().collect();
752    keys.extend(after.keys());
753    keys.into_iter()
754        .filter(move |key| before.get(*key) != after.get(*key))
755}
756
757/// What a read-only call left in the untracked and ignored tree.
758///
759/// Split rather than reduced to a yes or no, because the two halves are
760/// answered differently: one ends the call, the other is said out loud and the
761/// answer is kept.
762#[derive(Default)]
763pub(crate) struct ReadOnlyLeavings {
764    /// Disposable on both sides, so out of scope for a call that only read.
765    /// Recognized build output is left out: rebuilding is what a reviewer was
766    /// asked to do, and nothing reports it today.
767    pub(crate) tolerated: Vec<PathBuf>,
768    /// Everything else. The answer does not survive these.
769    pub(crate) condemning: Vec<PathBuf>,
770}
771
772/// Put two accounts of what moved into one sentence, keeping whichever of them
773/// there is. Both guards that report a changed worktree ask the tracked tree
774/// and the untracked tree separately, and either can be the only answer.
775pub(crate) fn join_reasons(first: Option<String>, second: Option<String>) -> Option<String> {
776    match (first, second) {
777        (None, None) => None,
778        (Some(one), None) | (None, Some(one)) => Some(one),
779        (Some(first), Some(second)) => Some(format!("{first}; {second}")),
780    }
781}
782
783/// Name some paths for a person, without printing a build directory at them.
784///
785/// Every report of a path that stopped or was let through a call goes through
786/// this, so an error and the warning beside it read alike.
787pub(crate) fn list_paths(paths: &[PathBuf]) -> String {
788    const SHOWN: usize = 5;
789    let mut listed = paths
790        .iter()
791        .take(SHOWN)
792        .map(|path| format!("{:?}", path.as_os_str()))
793        .collect::<Vec<_>>()
794        .join(", ");
795    if paths.len() > SHOWN {
796        listed.push_str(&format!(", and {} more", paths.len() - SHOWN));
797    }
798    listed
799}
800
801/// Ignored files one commit attempt let through, gathered for a single report.
802///
803/// The checks that allow it run more than once per attempt, before staging,
804/// after staging, and again after the commit, because the tree could have moved
805/// under any of them. Reporting from inside each check said the same thing
806/// about the same files two and three times over.
807#[derive(Default)]
808struct IgnoredArtifacts {
809    new_paths: BTreeSet<PathBuf>,
810    changed_paths: BTreeSet<PathBuf>,
811    removed_paths: BTreeSet<PathBuf>,
812}
813
814impl IgnoredArtifacts {
815    fn left(&mut self, allowed: AllowedArtifacts) {
816        self.new_paths.extend(allowed.present);
817        self.removed_paths.extend(allowed.removed);
818    }
819
820    fn changed(&mut self, allowed: AllowedArtifacts) {
821        self.changed_paths.extend(allowed.present);
822        self.removed_paths.extend(allowed.removed);
823    }
824
825    /// Report paths without claiming ignored files are safe to delete.
826    fn report(&self, cwd: &Path) {
827        let paths: BTreeSet<_> = self
828            .new_paths
829            .iter()
830            .chain(&self.changed_paths)
831            .chain(&self.removed_paths)
832            .cloned()
833            .collect();
834        if !paths.is_empty() {
835            logdim!(
836                "the editing call changed {} ignored file(s) in {}: {}. They are outside \
837                 the commit; cleanup checks them separately.",
838                paths.len(),
839                cwd.display(),
840                list_paths(&paths.into_iter().collect::<Vec<_>>())
841            );
842        }
843    }
844}
845
846/// Ignored files a check let through, split by whether each one
847/// is still on disk, so the report can say what happened rather than call a
848/// deleted incremental session a file that was left behind.
849#[derive(Default)]
850struct AllowedArtifacts {
851    present: Vec<PathBuf>,
852    removed: Vec<PathBuf>,
853}
854
855impl AllowedArtifacts {
856    fn split(paths: Vec<PathBuf>, after: &IgnoredState) -> Self {
857        let (present, removed) = paths
858            .into_iter()
859            .partition(|path| after.files.contains_key(path));
860        Self { present, removed }
861    }
862}
863
864/// Generated directories that test and build commands routinely create.
865///
866/// Files under these directories are never committed, and they do not keep a
867/// worktree that is otherwise finished, so running the requested tests neither
868/// stops the tracked change reaching review nor leaves a checkout behind.
869fn is_generated_artifact(path: &Path) -> bool {
870    const DIRECTORIES: &[&str] = &[
871        "target",
872        "dist",
873        "node_modules",
874        "__pycache__",
875        ".pytest_cache",
876        ".mypy_cache",
877        ".ruff_cache",
878        ".tox",
879        ".nox",
880        ".venv",
881        "venv",
882        ".gradle",
883        ".build",
884        "DerivedData",
885        ".next",
886        ".nuxt",
887        ".svelte-kit",
888        ".turbo",
889        "coverage",
890    ];
891    path.components().any(|component| {
892        let std::path::Component::Normal(name) = component else {
893            return false;
894        };
895        DIRECTORIES
896            .iter()
897            .any(|directory| name == OsStr::new(directory))
898    })
899}
900
901/// Files the desktop writes into any folder a person opens.
902///
903/// Finder drops `.DS_Store` into every directory it shows, so a finished
904/// worktree someone once looked at in Finder held nothing but these, and
905/// `spar clean` kept every one of them as if it were work.
906fn is_desktop_metadata(path: &Path) -> bool {
907    path.file_name() == Some(OsStr::new(".DS_Store"))
908}
909
910fn merge_pr_args<'a>(
911    number: &'a str,
912    expected_head: Option<&'a str>,
913    delete_branch: bool,
914) -> Vec<&'a str> {
915    let mut args = vec!["pr", "merge", number, "--squash"];
916    if delete_branch {
917        args.push("--delete-branch");
918    }
919    if let Some(expected_head) = expected_head {
920        args.extend(["--match-head-commit", expected_head]);
921    }
922    args
923}
924
925/// The line of a failed push where the host says this account may not write
926/// to the repository, as opposed to the branch having moved or the network
927/// failing.
928///
929/// GitHub words it the same over SSH (`ERROR: Permission to o/r.git denied to
930/// u.`) and HTTPS (`remote: Permission to ...`), and in both it comes before
931/// several lines of generic advice, so the last line is the least useful one:
932/// it used to be all spar said, and all it said was "and the repository
933/// exists."
934fn push_denial(message: &str) -> Option<&str> {
935    message
936        .lines()
937        .map(|line| {
938            let line = line.trim();
939            let line = line.strip_prefix("ERROR:").unwrap_or(line);
940            line.strip_prefix("remote:").unwrap_or(line).trim()
941        })
942        .find(|line| line.starts_with("Permission to ") && line.contains(" denied to "))
943}
944
945/// The line of a failed push that names the ref the remote turned down and
946/// why, such as `[rejected] HEAD -> issue-7 (stale info)`.
947fn push_rejection(message: &str) -> Option<&str> {
948    message
949        .lines()
950        .map(|line| line.trim().trim_start_matches('!').trim())
951        .find(|line| line.starts_with("[rejected]") || line.starts_with("[remote rejected]"))
952}
953
954fn push_error(
955    target: &PushTarget,
956    branch: &str,
957    e: &crate::error::SparError,
958) -> crate::error::SparError {
959    let message = e.to_string();
960    if let Some(denied) = push_denial(&message) {
961        return spar_err!("could not push to {}. {denied}", target.describe(branch));
962    }
963    spar_err!(
964        "could not push to {}. {}\nCheck push access and whether the branch moved under you.",
965        target.describe(branch),
966        push_rejection(&message).unwrap_or(e.last_line())
967    )
968}
969
970/// The fork's URL, reached the way origin is: origin's push URL with only its
971/// `owner/repo` path replaced.
972///
973/// Keeping everything but the path is what keeps an SSH host alias, which is
974/// how one machine pushes as two GitHub accounts, a port, such as SSH over
975/// 443 where 22 is blocked, and an scp-style address with no user in it.
976/// GitHub's own URLs for the fork would drop all three. None for anything
977/// that is not a URL or an scp-style address with an `owner/repo` path.
978fn fork_url(origin_url: &str, full_name: &str) -> Option<String> {
979    let origin = origin_url.trim();
980    let path_start = match origin.find("://") {
981        Some(scheme) => {
982            let host = scheme + 3;
983            host + origin[host..].find('/')? + 1
984        }
985        None => {
986            // scp-style is `[user@]host:path`, and git reads a colon after a
987            // slash as part of a local path instead.
988            let colon = origin.find(':')?;
989            if origin[..colon].contains('/') {
990                return None;
991            }
992            colon + 1
993        }
994    };
995    let path = origin[path_start..].trim_end_matches('/');
996    let (path, suffix) = match path.strip_suffix(".git") {
997        Some(bare) => (bare, ".git"),
998        None => (path, ""),
999    };
1000    let segments: Vec<&str> = path.split('/').collect();
1001    if segments.len() < 2 || segments[segments.len() - 2..].iter().any(|s| s.is_empty()) {
1002        return None;
1003    }
1004    let prefix = &segments[..segments.len() - 2];
1005    let prefix = if prefix.is_empty() {
1006        String::new()
1007    } else {
1008        format!("{}/", prefix.join("/"))
1009    };
1010    Some(format!(
1011        "{}{prefix}{full_name}{suffix}",
1012        &origin[..path_start]
1013    ))
1014}
1015
1016/// A fork from what GitHub said about it, reached the way origin is.
1017///
1018/// GitHub's own URL is the fallback for an origin `fork_url` cannot read, the
1019/// SSH one when origin looks like SSH.
1020fn fork_reached_like_origin(
1021    owner: String,
1022    full_name: String,
1023    origin_url: &str,
1024    ssh_url: String,
1025    https_url: String,
1026) -> Fork {
1027    let origin = origin_url.trim();
1028    let url = fork_url(origin, &full_name).unwrap_or_else(|| {
1029        if origin.starts_with("git@") || origin.starts_with("ssh://") {
1030            ssh_url
1031        } else {
1032            https_url
1033        }
1034    });
1035    Fork {
1036        owner,
1037        full_name,
1038        url,
1039    }
1040}
1041
1042#[derive(Deserialize)]
1043struct ForkOwner {
1044    login: String,
1045}
1046
1047/// The fork GitHub made, or already had, in answer to a request to fork.
1048fn fork_from_api(answer: &str, origin_url: &str) -> Result<Fork> {
1049    #[derive(Deserialize)]
1050    struct Answer {
1051        full_name: String,
1052        owner: ForkOwner,
1053        ssh_url: String,
1054        clone_url: String,
1055    }
1056
1057    let answer: Answer = serde_json::from_str(answer.trim())
1058        .map_err(|e| spar_err!("unexpected answer from GitHub when forking: {e}"))?;
1059    Ok(fork_reached_like_origin(
1060        answer.owner.login,
1061        answer.full_name,
1062        origin_url,
1063        answer.ssh_url,
1064        answer.clone_url,
1065    ))
1066}
1067
1068/// The authenticated user's fork from a listing of this repository's forks
1069/// that only the user owns, or None when there is no such fork.
1070fn fork_from_listing(answer: &str, origin_url: &str) -> Result<Option<Fork>> {
1071    #[derive(Deserialize)]
1072    #[serde(rename_all = "camelCase")]
1073    struct Node {
1074        name_with_owner: String,
1075        owner: ForkOwner,
1076        ssh_url: String,
1077        url: String,
1078    }
1079
1080    let nodes: Vec<Node> = serde_json::from_str(answer.trim())
1081        .map_err(|e| spar_err!("unexpected answer from GitHub listing forks: {e}"))?;
1082    Ok(nodes.into_iter().next().map(|node| {
1083        fork_reached_like_origin(
1084            node.owner.login,
1085            node.name_with_owner,
1086            origin_url,
1087            node.ssh_url,
1088            node.url,
1089        )
1090    }))
1091}
1092
1093/// Where spar can push a pull request from a fork: the fork, when it is the
1094/// authenticated user's own, and nowhere when it is anybody else's.
1095fn own_fork_target(pr: &PrView, viewer: &str, origin_url: &str) -> Option<PushTarget> {
1096    let owner = pr.head_repository_owner.as_ref()?.login.trim();
1097    let name = pr.head_repository.as_ref()?.name.trim();
1098    if owner.is_empty() || name.is_empty() || !owner.eq_ignore_ascii_case(viewer.trim()) {
1099        return None;
1100    }
1101    let full_name = format!("{owner}/{name}");
1102    Some(PushTarget::Fork(Fork {
1103        owner: owner.to_string(),
1104        url: fork_url(origin_url, &full_name)?,
1105        full_name,
1106    }))
1107}
1108
1109fn reconcile_pr_creation(
1110    branch: &str,
1111    created: Result<String>,
1112    found: Result<Option<PrRef>>,
1113) -> Result<PrRef> {
1114    match (created, found) {
1115        (_, Ok(Some(pr))) => Ok(pr),
1116        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1117            "PR creation reported success but none was found for {branch}"
1118        ))),
1119        (Err(create), Ok(None)) => Err(spar_err!(
1120            "could not open a PR for {branch}. {}",
1121            create.last_line()
1122        )),
1123        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1124            "PR creation reported success for {branch}, but it could not be verified. {}",
1125            check.last_line()
1126        ))),
1127        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1128            "could not open a PR for {branch}. {} The result could not be verified: {}",
1129            create.last_line(),
1130            check.last_line()
1131        ))),
1132    }
1133}
1134
1135fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
1136    #[derive(Deserialize)]
1137    #[serde(rename_all = "camelCase")]
1138    struct Row {
1139        number: i64,
1140        #[serde(default)]
1141        url: String,
1142        #[serde(default)]
1143        title: String,
1144        base_ref_name: String,
1145    }
1146
1147    let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
1148        spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
1149    })?;
1150    Ok(rows
1151        .into_iter()
1152        .find(|row| row.base_ref_name == base)
1153        .map(|row| PrRef {
1154            number: row.number,
1155            url: row.url,
1156            title: row.title,
1157        }))
1158}
1159
1160/// Sign a comment spar composed, so it can be told from one the same account
1161/// wrote by hand. Idempotent: a body already signed is returned as it is.
1162fn signed(body: &str) -> String {
1163    if body.contains(COMMENT_MARKER) {
1164        return body.to_string();
1165    }
1166    format!("{body}\n\n{COMMENT_MARKER}")
1167}
1168
1169/// Whether a comment body is one spar composed rather than one a person wrote.
1170pub fn is_spar_comment(body: &str) -> bool {
1171    body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
1172}
1173
1174/// What `gh api -i` printed, taken apart.
1175#[derive(Debug, Clone, Default, PartialEq, Eq)]
1176pub struct ApiResponse {
1177    pub status: u16,
1178    /// Header names lowercased, values trimmed.
1179    pub headers: BTreeMap<String, String>,
1180    pub body: String,
1181}
1182
1183/// Split `gh api -i` output into the status, the headers, and the body. None
1184/// when it does not start with a status line, which is what a spawn that
1185/// printed an error looks like.
1186pub fn parse_api_response(stdout: &str) -> Option<ApiResponse> {
1187    let mut lines = stdout.split_inclusive('\n');
1188    let first = lines.next()?.trim_end_matches(['\r', '\n']);
1189    if !first.starts_with("HTTP/") {
1190        return None;
1191    }
1192    let status: u16 = first.split_whitespace().nth(1)?.parse().ok()?;
1193    let mut headers = BTreeMap::new();
1194    let mut body = String::new();
1195    let mut in_body = false;
1196    for line in lines {
1197        if in_body {
1198            body.push_str(line);
1199            continue;
1200        }
1201        let bare = line.trim_end_matches(['\r', '\n']);
1202        if bare.is_empty() {
1203            in_body = true;
1204            continue;
1205        }
1206        if let Some((name, value)) = bare.split_once(':') {
1207            headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
1208        }
1209    }
1210    Some(ApiResponse {
1211        status,
1212        headers,
1213        body,
1214    })
1215}
1216
1217/// The review requests on a pull request's timeline. The last hundred are
1218/// plenty: only the newest one that asks the viewer matters.
1219const REQUESTS_QUERY: &str = "\
1220query($owner: String!, $repo: String!, $number: Int!) {
1221  repository(owner: $owner, name: $repo) {
1222    pullRequest(number: $number) {
1223      timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], last: 100) {
1224        nodes {
1225          ... on ReviewRequestedEvent {
1226            createdAt
1227            requestedReviewer {
1228              ... on User { login }
1229              ... on Team { combinedSlug }
1230            }
1231          }
1232        }
1233      }
1234    }
1235  }
1236}";
1237
1238/// The requests `REQUESTS_QUERY` returned, oldest first. None when the answer
1239/// is not that shape. A request for somebody GitHub no longer knows names
1240/// nobody and is left out, or it would read as a team nobody could check.
1241pub fn parse_review_requests(text: &str) -> Option<Vec<crate::model::ReviewRequested>> {
1242    let v: Value = serde_json::from_str(text).ok()?;
1243    let nodes = v
1244        .pointer("/data/repository/pullRequest/timelineItems/nodes")?
1245        .as_array()?;
1246    let text_at = |node: &Value, path: &str| {
1247        node.pointer(path)
1248            .and_then(Value::as_str)
1249            .unwrap_or_default()
1250            .to_string()
1251    };
1252    Some(
1253        nodes
1254            .iter()
1255            .map(|node| crate::model::ReviewRequested {
1256                at: text_at(node, "/createdAt"),
1257                who: crate::model::Login {
1258                    login: text_at(node, "/requestedReviewer/login"),
1259                    slug: text_at(node, "/requestedReviewer/combinedSlug"),
1260                },
1261            })
1262            .filter(|r| !r.at.is_empty() && !(r.who.login.is_empty() && r.who.slug.is_empty()))
1263            .collect(),
1264    )
1265}
1266
1267/// The pull request a per item state file is about: `pr-12.json`,
1268/// `review-12.json`, `checkin-12.json`. None for anything else, so the shared
1269/// files beside them, `spend.json` and the rest, are never touched.
1270pub(crate) fn state_file_number(name: &str) -> Option<i64> {
1271    let stem = name.strip_suffix(".json")?;
1272    ["pr-", "review-", "checkin-"]
1273        .iter()
1274        .find_map(|prefix| stem.strip_prefix(prefix))
1275        .filter(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
1276        .and_then(|digits| digits.parse().ok())
1277}
1278
1279/// Whether the viewer has already submitted this review: exactly this body, on
1280/// this commit, and still standing. A clean review reads the same on every
1281/// commit, so a body alone would take an approval of an older commit for this
1282/// one; and a dismissed review was withdrawn, so it is not this one either.
1283fn has_viewer_review(reviews: &[Value], viewer: &str, body: &str, commit: &str) -> bool {
1284    reviews.iter().any(|review| {
1285        let field = |name: &str| review.get(name).and_then(Value::as_str).unwrap_or("");
1286        let standing = !matches!(
1287            field("state").to_uppercase().as_str(),
1288            "DISMISSED" | "PENDING"
1289        );
1290        review
1291            .get("user")
1292            .and_then(|u| u.get("login"))
1293            .and_then(Value::as_str)
1294            .is_some_and(|login| crate::comments::same_login(login, viewer))
1295            && field("body") == body
1296            && field("commit_id").eq_ignore_ascii_case(commit)
1297            && standing
1298    })
1299}
1300
1301fn reconcile_review_post(
1302    number: i64,
1303    body: &str,
1304    viewer: &str,
1305    commit: &str,
1306    post_error: crate::error::SparError,
1307    reviews: Result<Vec<Value>>,
1308) -> Result<()> {
1309    match reviews {
1310        Ok(reviews) if has_viewer_review(&reviews, viewer, body, commit) => Ok(()),
1311        Ok(_) => Err(post_error),
1312        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1313            "could not submit a review on #{number}. {} The result could not be verified: {}",
1314            post_error.last_line(),
1315            read_error.last_line()
1316        ))),
1317    }
1318}
1319
1320/// The body of a review that carries line comments, for `gh api --input -`.
1321///
1322/// Every comment is on the new side of the diff, which is the only side a
1323/// finding about the branch is ever about.
1324fn review_payload(commit: &str, event: &str, body: &str, comments: &[InlineComment]) -> Value {
1325    let comments: Vec<Value> = comments
1326        .iter()
1327        .map(|c| {
1328            let mut row = serde_json::json!({
1329                "path": c.path,
1330                "line": c.line,
1331                "side": "RIGHT",
1332                "body": c.body,
1333            });
1334            if let Some(start) = c.start_line {
1335                row["start_line"] = serde_json::json!(start);
1336                row["start_side"] = serde_json::json!("RIGHT");
1337            }
1338            row
1339        })
1340        .collect();
1341    serde_json::json!({
1342        "commit_id": commit,
1343        "event": event,
1344        "body": body,
1345        "comments": comments,
1346    })
1347}
1348
1349/// Whether GitHub looked at a write and refused it, rather than failing to
1350/// answer. A refusal is final, so the same write changed is safe to send; a
1351/// timeout or a gateway error says nothing about whether the first one landed.
1352fn refused(e: &crate::error::SparError) -> bool {
1353    e.to_string().contains("(HTTP 422)")
1354}
1355
1356/// The lines of each file a pull request's diff shows on its new side, as
1357/// first and last line of each hunk. GitHub takes a line comment only on one
1358/// of these, and refuses the whole review over one that is not.
1359pub type Hunks = BTreeMap<String, Vec<(u32, u32)>>;
1360
1361/// Read the hunks out of a unified diff.
1362///
1363/// A hunk's lines are counted off against its header rather than recognised by
1364/// their first character, so an added line that itself begins `+++ ` or `@@ `
1365/// is content and not the start of something. A path git had to quote is left
1366/// out: what is between the quotes is escaped, not a path GitHub would match.
1367fn new_side_hunks(diff: &str) -> Hunks {
1368    let mut out = Hunks::new();
1369    let mut path: Option<String> = None;
1370    let (mut old_left, mut new_left) = (0u32, 0u32);
1371    for line in diff.lines() {
1372        if old_left > 0 || new_left > 0 {
1373            match line.as_bytes().first() {
1374                Some(b'+') => new_left = new_left.saturating_sub(1),
1375                Some(b'-') => old_left = old_left.saturating_sub(1),
1376                Some(b'\\') => {}
1377                _ => {
1378                    old_left = old_left.saturating_sub(1);
1379                    new_left = new_left.saturating_sub(1);
1380                }
1381            }
1382            continue;
1383        }
1384        if line.starts_with("diff --git ") {
1385            path = None;
1386        } else if let Some(rest) = line.strip_prefix("+++ ") {
1387            // git ends the line with a tab when the path has a space in it,
1388            // so that a patch tool can tell where the name stops.
1389            let rest = if rest.contains(' ') {
1390                rest.strip_suffix('\t').unwrap_or(rest)
1391            } else {
1392                rest
1393            };
1394            path = rest
1395                .strip_prefix("b/")
1396                .filter(|p| !p.is_empty())
1397                .map(str::to_string);
1398        } else if let Some(((_, old_len), (start, len))) = hunk_header(line) {
1399            old_left = old_len;
1400            new_left = len;
1401            if let (Some(path), true) = (&path, len > 0 && start > 0) {
1402                out.entry(path.clone())
1403                    .or_default()
1404                    .push((start, start + len - 1));
1405            }
1406        }
1407    }
1408    out
1409}
1410
1411/// `@@ -a,b +c,d @@`, as `((a, b), (c, d))`. A count left out is one.
1412fn hunk_header(line: &str) -> Option<((u32, u32), (u32, u32))> {
1413    let ranges = line.strip_prefix("@@ -")?.split_once(" @@")?.0;
1414    let (old, new) = ranges.split_once(" +")?;
1415    let range = |s: &str| -> Option<(u32, u32)> {
1416        match s.split_once(',') {
1417            Some((start, len)) => Some((start.parse().ok()?, len.parse().ok()?)),
1418            None => Some((s.parse().ok()?, 1)),
1419        }
1420    };
1421    Some((range(old)?, range(new)?))
1422}
1423
1424fn has_exact_comment(comments: &[Value], body: &str) -> bool {
1425    comments.iter().any(|comment| {
1426        comment
1427            .get("body")
1428            .and_then(Value::as_str)
1429            .is_some_and(|seen| seen == body)
1430    })
1431}
1432
1433fn reconcile_comment_post(
1434    number: i64,
1435    body: &str,
1436    post_error: crate::error::SparError,
1437    comments: Result<Vec<Value>>,
1438) -> Result<()> {
1439    match comments {
1440        Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
1441        Ok(_) => Err(post_error),
1442        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1443            "could not comment on #{number}. {} The result could not be verified: {}",
1444            post_error.last_line(),
1445            read_error.last_line()
1446        ))),
1447    }
1448}
1449
1450fn reconcile_issue_edit(
1451    number: i64,
1452    wanted: &str,
1453    edit_error: crate::error::SparError,
1454    observed: Result<String>,
1455) -> Result<()> {
1456    match observed {
1457        Ok(body) if body == wanted => Ok(()),
1458        Ok(_) => Err(spar_err!(
1459            "could not rewrite the body of #{number}. {}",
1460            edit_error.last_line()
1461        )),
1462        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1463            "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1464            edit_error.last_line(),
1465            read_error.last_line()
1466        ))),
1467    }
1468}
1469
1470fn issue_url_has_number(url: &str) -> bool {
1471    url.trim()
1472        .rsplit('/')
1473        .next()
1474        .and_then(|tail| tail.parse::<i64>().ok())
1475        .is_some_and(|number| number > 0)
1476}
1477
1478fn reconcile_issue_creation(
1479    title: &str,
1480    created: Result<String>,
1481    found: Result<Option<ExistingIssue>>,
1482) -> Result<String> {
1483    match (created, found) {
1484        (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1485        (_, Ok(Some(issue))) => Ok(issue.url),
1486        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1487            "issue creation reported success but no matching issue was found for {title:?}"
1488        ))),
1489        (Err(create), Ok(None)) => Err(spar_err!(
1490            "could not file issue {title:?}. {}",
1491            create.last_line()
1492        )),
1493        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1494            "issue creation reported success for {title:?}, but it could not be verified. {}",
1495            check.last_line()
1496        ))),
1497        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1498            "could not file issue {title:?}. {} The result could not be verified: {}",
1499            create.last_line(),
1500            check.last_line()
1501        ))),
1502    }
1503}
1504
1505fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1506    if output.trim().is_empty() {
1507        return Ok(None);
1508    }
1509    for line in output.lines() {
1510        let mut fields = line.split_whitespace();
1511        let oid = fields.next().unwrap_or_default();
1512        let name = fields.next().unwrap_or_default();
1513        if name == remote_ref && !oid.is_empty() {
1514            return Ok(Some(oid.to_string()));
1515        }
1516    }
1517    Err(spar_err!(
1518        "origin returned an unexpected ref listing for {remote_ref}"
1519    ))
1520}
1521
1522fn reconcile_failed_split_push(
1523    branch: &str,
1524    push_error: crate::error::SparError,
1525    local: Result<String>,
1526    remote: Result<String>,
1527) -> std::result::Result<(), SplitPushError> {
1528    let remote_ref = format!("refs/heads/{branch}");
1529    match (local, remote) {
1530        (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1531            Ok(Some(oid)) if oid == local.trim() => Ok(()),
1532            Ok(_) => Err(SplitPushError::new(
1533                format!(
1534                    "could not create origin/{branch}. {} The remote branch is absent or points \
1535                     somewhere else. Nothing was overwritten.",
1536                    push_error.last_line()
1537                ),
1538                false,
1539            )),
1540            Err(check) => Err(SplitPushError::new(
1541                format!(
1542                    "could not confirm whether origin/{branch} was created. {} The remote result \
1543                     could not be verified: {}",
1544                    push_error.last_line(),
1545                    check.last_line()
1546                ),
1547                true,
1548            )),
1549        },
1550        (local, remote) => {
1551            let check = match (local, remote) {
1552                (Err(local), Err(remote)) => format!(
1553                    "the local commit could not be read: {}; origin could not be read: {}",
1554                    local.last_line(),
1555                    remote.last_line()
1556                ),
1557                (Err(local), _) => {
1558                    format!("the local commit could not be read: {}", local.last_line())
1559                }
1560                (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1561                _ => unreachable!(),
1562            };
1563            Err(SplitPushError::new(
1564                format!(
1565                    "could not confirm whether origin/{branch} was created. {} The result could \
1566                     not be verified because {check}",
1567                    push_error.last_line()
1568                ),
1569                true,
1570            ))
1571        }
1572    }
1573}
1574
1575impl Repo {
1576    pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1577        let root =
1578            std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1579        // A linked worktree has a `.git` file rather than a directory, and a
1580        // bare-ish layout can have neither, so ask git instead of guessing.
1581        let inside = proc::run_str(
1582            &["git", "rev-parse", "--is-inside-work-tree"],
1583            &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1584        )
1585        .unwrap_or_default();
1586        if inside.trim() != "true" {
1587            bail!("not a git repository: {}", root.display());
1588        }
1589        let repo = Self {
1590            root,
1591            style: cfg.style.clone(),
1592            branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1593            state_store: cfg.loop_cfg.state_store,
1594            followups: cfg.loop_cfg.followups,
1595            drafts: cfg.loop_cfg.drafts,
1596            fork_when_denied: cfg.loop_cfg.fork_when_denied,
1597            setup: cfg.loop_cfg.setup.clone(),
1598            isolate_reads: cfg.loop_cfg.worktrees,
1599            keep_worktrees: cfg.loop_cfg.keep_worktrees,
1600            fork: OnceLock::new(),
1601            viewer: OnceLock::new(),
1602            checkpoints: Mutex::new(BTreeMap::new()),
1603            writes: WriteStats::default(),
1604        };
1605        repo.self_exclude();
1606        Ok(repo)
1607    }
1608
1609    /// Keep spar's own scratch directories out of the target repo's
1610    /// `git status`.
1611    ///
1612    /// Written to `.git/info/exclude`, never to a tracked `.gitignore`: this is
1613    /// somebody else's repository and spar has no business committing to it.
1614    /// Best effort and silent on failure, because a read-only git directory is
1615    /// not a reason to abandon a run.
1616    fn self_exclude(&self) {
1617        let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1618        let git_dir = git_dir.trim();
1619        if git_dir.is_empty() {
1620            return;
1621        }
1622        let path = Path::new(git_dir).join("info").join("exclude");
1623        let existing = std::fs::read_to_string(&path).unwrap_or_default();
1624
1625        // The marker too. It is written into whichever directory the call ran
1626        // in, which for a shared checkout is the repository root, and an
1627        // untracked file there is one `git add -A` away from being committed.
1628        // The error names its path, which is how a person is meant to find it.
1629        let wanted = [
1630            format!("/{WORKTREE_DIR}/"),
1631            format!("/{STATE_DIR}/"),
1632            format!("{RECOVERY_MARKER_PREFIX}*"),
1633        ];
1634        let missing: Vec<&String> = wanted
1635            .iter()
1636            .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1637            .collect();
1638        if missing.is_empty() {
1639            return;
1640        }
1641
1642        use std::io::Write;
1643        if let Some(parent) = path.parent() {
1644            let _ = std::fs::create_dir_all(parent);
1645        }
1646        let mut block = String::new();
1647        if !existing.is_empty() && !existing.ends_with('\n') {
1648            block.push('\n');
1649        }
1650        block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1651        for line in missing {
1652            block.push_str(line);
1653            block.push('\n');
1654        }
1655        if let Ok(mut file) = std::fs::OpenOptions::new()
1656            .create(true)
1657            .append(true)
1658            .open(&path)
1659        {
1660            let _ = file.write_all(block.as_bytes());
1661        }
1662    }
1663
1664    pub fn root(&self) -> &Path {
1665        &self.root
1666    }
1667
1668    pub(crate) fn write_summary(&self) -> WriteSummary {
1669        WriteSummary {
1670            attempted: self.writes.attempted.load(Ordering::Relaxed),
1671            failed: self.writes.failed.load(Ordering::Relaxed),
1672        }
1673    }
1674
1675    pub(crate) fn record_write<T, E>(
1676        &self,
1677        result: std::result::Result<T, E>,
1678    ) -> std::result::Result<T, E> {
1679        self.record_write_outcome(result.is_err());
1680        result
1681    }
1682
1683    pub(crate) fn record_failed_write<T, E>(
1684        &self,
1685        result: std::result::Result<T, E>,
1686    ) -> std::result::Result<T, E> {
1687        if result.is_err() {
1688            self.record_write_outcome(true);
1689        }
1690        result
1691    }
1692
1693    fn record_write_outcome(&self, failed: bool) {
1694        self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1695        if failed {
1696            self.writes.failed.fetch_add(1, Ordering::Relaxed);
1697        }
1698    }
1699
1700    // -- gates ------------------------------------------------------------
1701
1702    /// Scrub, then verify. A leak here reaches GitHub, so it is a hard error
1703    /// rather than a warning: silent partial compliance is how a style rule
1704    /// erodes over a long run.
1705    pub fn clean(&self, text: &str) -> Result<String> {
1706        let out = style::scrub(text, &self.style);
1707        let bad = style::violations(&out, &self.style);
1708        if !bad.is_empty() {
1709            bail!(
1710                "style gate could not clean text ({}): {}",
1711                bad.join(", "),
1712                style::clip(&out, 300)
1713            );
1714        }
1715        Ok(out)
1716    }
1717
1718    /// Clean, and hold to a length budget. For anything a model wrote.
1719    pub fn clean_body(&self, text: &str) -> Result<String> {
1720        self.clean(&style::body(text, &self.style))
1721    }
1722
1723    /// The same, with an issue's far larger budget and its exemption for code.
1724    pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1725        self.clean(&style::issue_body(text, &self.style))
1726    }
1727
1728    /// The single transform every outbound title goes through.
1729    ///
1730    /// Scrub first, clip second, and never the other way round. Clipping first
1731    /// lets the scrub lengthen the result past the budget (an em dash becomes
1732    /// two characters), so a second pass would clip again and produce a
1733    /// different string. That broke follow-up deduplication silently: the
1734    /// lookup searched for one title while GitHub had stored another, no match
1735    /// was ever found, and a fresh duplicate issue was filed every review
1736    /// round. Doing it in this order makes the transform idempotent, which the
1737    /// tests assert.
1738    pub fn clean_title(&self, text: &str) -> Result<String> {
1739        Ok(style::title(&self.clean(text)?, &self.style))
1740    }
1741
1742    pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1743        let title = self.record_failed_write(self.clean_title(text))?;
1744        if title.trim().is_empty() {
1745            return self.record_failed_write(Err(spar_err!(
1746                "nothing left of the title after cleaning it"
1747            )));
1748        }
1749        Ok(title)
1750    }
1751
1752    pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1753        if self.followups == Followups::Issues {
1754            self.clean_nonempty_title_for_write(text)
1755        } else {
1756            self.clean_title(text)
1757        }
1758    }
1759
1760    // -- git --------------------------------------------------------------
1761
1762    fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1763        ExecOpts::new()
1764            .cwd(cwd.unwrap_or(&self.root))
1765            .check(check)
1766            .timeout_secs(600)
1767    }
1768
1769    pub fn git(&self, args: &[&str]) -> Result<String> {
1770        self.git_at(None, args)
1771    }
1772
1773    pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1774        let argv = git_without_maintenance_argv(args);
1775        proc::run(&argv, &self.git_opts(cwd, true))
1776    }
1777
1778    /// Run a parent-side Git operation without inherited background helpers.
1779    ///
1780    /// Editing calls are untrusted. Once one returns, status, staging, and
1781    /// committing happen in this process, so an inherited fsmonitor or automatic
1782    /// maintenance command must not become a way to execute outside its sandbox.
1783    fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1784        let argv = git_without_automation_argv(args);
1785        proc::run(
1786            &argv,
1787            &self.git_opts(Some(cwd), true).stop_descendants(true),
1788        )
1789    }
1790
1791    fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1792        let argv = git_without_automation_argv(args);
1793        proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1794            .map(|output| output.ok())
1795    }
1796
1797    /// Run git, tolerating failure. Returns whatever landed on stdout.
1798    pub fn git_try(&self, args: &[&str]) -> String {
1799        self.git_try_at(None, args)
1800    }
1801
1802    pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1803        let argv = git_without_maintenance_argv(args);
1804        proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1805    }
1806
1807    /// The base branch the remote actually points at, rather than assuming
1808    /// `main`. Falls back to the configured value when there is no origin.
1809    pub fn default_branch(&self, configured: &str) -> String {
1810        let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1811        match refname.trim().rsplit('/').next() {
1812            Some(name) if !name.is_empty() => name.to_string(),
1813            _ => configured.to_string(),
1814        }
1815    }
1816
1817    // -- branch naming and ownership --------------------------------------
1818    //
1819    // Branch names default to `issue-N`, which is exactly what a person would
1820    // name a branch by hand. Ownership therefore cannot be inferred from the
1821    // name, so every branch spar creates is recorded and cleanup only ever
1822    // touches what is in that record.
1823
1824    pub fn branch_for_issue(&self, issue: i64) -> String {
1825        format!("{}issue-{issue}", self.branch_prefix)
1826    }
1827
1828    pub fn branch_for_pr(&self, number: i64) -> String {
1829        format!("{}pr-{number}", self.branch_prefix)
1830    }
1831
1832    /// One part of a split, numbered from 1 within its parent.
1833    ///
1834    /// Its own namespace rather than `issue-N`, because the parts of a split
1835    /// pull request have no issue of their own and would otherwise collide with
1836    /// the branch of the issue that happens to share the parent's number.
1837    ///
1838    /// The name a part is tried on first. `worktree_for_split` may end up on a
1839    /// suffixed one, because this name is not free forever.
1840    pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1841        format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1842    }
1843
1844    fn ledger_path(&self) -> PathBuf {
1845        self.root.join(STATE_DIR).join("branches.json")
1846    }
1847
1848    pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1849        std::fs::read_to_string(self.ledger_path())
1850            .ok()
1851            .and_then(|text| serde_json::from_str(&text).ok())
1852            .unwrap_or_default()
1853    }
1854
1855    pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1856        let mut data = self.known_branches();
1857        data.insert(
1858            branch.to_string(),
1859            BranchRecord {
1860                kind: kind.to_string(),
1861                number,
1862            },
1863        );
1864        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1865            logdim!("could not record branch {branch}: {e}");
1866        }
1867    }
1868
1869    pub fn forget_branch(&self, branch: &str) {
1870        let mut data = self.known_branches();
1871        if data.remove(branch).is_none() {
1872            return;
1873        }
1874        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1875            logdim!("could not update the branch record: {e}");
1876        }
1877    }
1878
1879    // -- worktrees --------------------------------------------------------
1880
1881    fn worktree_path(&self, name: &str) -> PathBuf {
1882        self.root.join(WORKTREE_DIR).join(name)
1883    }
1884
1885    /// Run `[loop] setup` in a worktree spar has just created.
1886    ///
1887    /// Before anything else looks at the tree, so the baseline an editing call
1888    /// is measured against already holds what setup wrote, and a setup that
1889    /// fails costs no agent call. The worktree is kept as it is: what an
1890    /// install writes is build output, which the next run rebuilds over.
1891    fn set_up_worktree(&self, path: &Path, label: &str) -> Result<()> {
1892        let argv = self.setup.argv();
1893        if argv.is_empty() {
1894            return Ok(());
1895        }
1896        let command = self.setup.describe();
1897        logdim!("setting up the {label}: {command}");
1898        let out = proc::exec(
1899            &argv,
1900            &ExecOpts::new()
1901                .cwd(path)
1902                .check(false)
1903                .timeout_secs(SETUP_TIMEOUT_SECS)
1904                .stop_descendants(true),
1905        )
1906        .map_err(|e| spar_err!("could not run the setup `{command}` for the {label}: {e}"))?;
1907        if !out.ok() {
1908            let text = format!("{}\n{}", out.stdout.trim_end(), out.stderr.trim_end());
1909            bail!(
1910                "the setup `{command}` failed in the {label} at {}, so no agent was asked to \
1911                 work in it.\n{}",
1912                path.display(),
1913                style::clip(text.trim(), 4000)
1914            );
1915        }
1916        Ok(())
1917    }
1918
1919    /// Isolate an issue so a failed run cannot poison the next one's base.
1920    pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1921        let branch = self.branch_for_issue(issue);
1922        let path = self.worktree_path(&format!("issue-{issue}"));
1923
1924        self.refuse_issue_branch_rebuild(issue, base)?;
1925        self.refuse_dirty_worktree(
1926            &path,
1927            &format!("worktree for issue #{issue}"),
1928            Disposable::BuildOutput,
1929        )?;
1930
1931        if !self.branch_deletion_is_safe(&branch)? {
1932            bail!(
1933                "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1934                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1935                 before retrying."
1936            );
1937        }
1938
1939        if !self.remove_worktree_at(&path)? {
1940            bail!(
1941                "the existing worktree for issue #{issue} could not be removed safely. Its \
1942                 branch was kept."
1943            );
1944        }
1945        if !self.delete_branch_if_safe(&branch)? {
1946            bail!(
1947                "the existing branch {branch} changed or remained checked out while its \
1948                 worktree was being rebuilt. It was kept."
1949            );
1950        }
1951
1952        if let Some(parent) = path.parent() {
1953            std::fs::create_dir_all(parent)
1954                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1955        }
1956
1957        let path_str = path.display().to_string();
1958        let remote_start = format!("origin/{base}");
1959        let created = self
1960            .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1961            .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1962
1963        // Recorded on both paths: an unrecorded branch is one cleanup will
1964        // never remove, and the fallback creates a branch just the same.
1965        created.map_err(|e| {
1966            spar_err!(
1967                "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1968                 and does `origin` exist?",
1969                e.last_line()
1970            )
1971        })?;
1972        self.record_branch(&branch, "issue", issue);
1973        self.set_up_worktree(&path, &format!("worktree for issue #{issue}"))?;
1974        Ok((path, branch))
1975    }
1976
1977    /// Refuse to reset the local or remote branch assigned to an issue when it
1978    /// carries work that no pull request preserves.
1979    ///
1980    /// Both linked-worktree mode and shared-checkout mode rebuild the same
1981    /// branch name. Keeping the guard here prevents either path from silently
1982    /// replacing recovery commits left by an earlier run.
1983    pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1984        let branch = self.branch_for_issue(issue);
1985        let base_remote_ref = format!("refs/heads/{base}");
1986        let base_tracking_ref = format!("refs/remotes/origin/{base}");
1987        let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1988        self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1989            .map_err(|e| {
1990                spar_err!(
1991                    "could not refresh origin/{base} before checking issue #{issue}: {}",
1992                    e.last_line()
1993                )
1994            })?;
1995
1996        if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
1997            let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
1998            if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
1999                bail!(
2000                    "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
2001                     pull request accounts for them. Rebuilding it would force push over that \
2002                     work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
2003                     it, or delete it with `git push origin --delete {branch}` if the remote \
2004                     branch is no longer needed."
2005                );
2006            }
2007        }
2008
2009        // The same question of the fork a refused push would go to, asked here
2010        // rather than after an agent has spent its call implementing.
2011        if self.fork_when_denied && self.origin_refuses_push() {
2012            match self.existing_fork() {
2013                Ok(Some(fork)) => {
2014                    self.fork_branch_lease(&fork, &branch, base)?;
2015                }
2016                Ok(None) => {}
2017                Err(e) => logdim!("{e} It is checked again before anything is pushed to it."),
2018            }
2019        }
2020
2021        let local_ref = format!("refs/heads/{branch}");
2022        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2023            let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
2024            let recorded_pr = self
2025                .known_branches()
2026                .get(&branch)
2027                .is_some_and(|record| record.kind == "pr");
2028            let preserved = ahead == 0
2029                || if recorded_pr {
2030                    self.local_branch_is_preserved(&branch)?
2031                } else {
2032                    self.pull_request_holds(&branch, &local_ref, base)
2033                };
2034            if !preserved {
2035                let listed = self
2036                    .commit_lines(&self.root, &local_ref, base)
2037                    .iter()
2038                    .map(|line| format!("  {line}"))
2039                    .collect::<Vec<_>>()
2040                    .join("\n");
2041                bail!(
2042                    "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
2043                     no pull request preserves them. Rebuilding it would delete the only copy.\n\
2044                     {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
2045                     it, or delete it with `git branch -D {branch}` if it is stale."
2046                );
2047            }
2048        }
2049        Ok(())
2050    }
2051
2052    fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
2053        let live_ref = format!("refs/heads/{branch}");
2054        let tracking_ref = format!("refs/remotes/origin/{branch}");
2055        let listed = self
2056            .git(&["ls-remote", "--heads", "origin", &live_ref])
2057            .map_err(|e| {
2058                spar_err!(
2059                    "could not verify whether origin/{branch} still exists: {}",
2060                    e.last_line()
2061                )
2062            })?;
2063
2064        if remote_head_oid(&listed, &live_ref)?.is_some() {
2065            let refspec = format!("+{live_ref}:{tracking_ref}");
2066            self.git(&["fetch", "--no-tags", "origin", &refspec])
2067                .map_err(|e| {
2068                    spar_err!(
2069                        "origin/{branch} exists but its tracking ref could not be refreshed: {}",
2070                        e.last_line()
2071                    )
2072                })?;
2073            if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2074                bail!("origin/{branch} was fetched but its tracking ref is missing");
2075            }
2076            return Ok(Some(tracking_ref));
2077        }
2078
2079        if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2080            return Ok(None);
2081        }
2082        let expected = self
2083            .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
2084            .trim()
2085            .to_string();
2086        self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
2087            .map_err(|e| {
2088                spar_err!(
2089                    "could not discard stale origin/{branch} tracking ref safely: {}",
2090                    e.last_line()
2091                )
2092            })?;
2093        if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2094            bail!(
2095                "origin/{branch} changed while its stale tracking ref was being removed. It was \
2096                 kept."
2097            );
2098        }
2099        Ok(None)
2100    }
2101
2102    /// Whether a pull request from `branch` already holds every commit `refname`
2103    /// has beyond `base`.
2104    ///
2105    /// GitHub serves `refs/pull/N/head` for as long as the repository lives, so
2106    /// commits that reached a pull request outlive the branch they were pushed
2107    /// from. A matching branch name does not establish that on its own: an
2108    /// issue worked twice reuses the name, and the merged pull request from the
2109    /// first round says nothing about where the second round's commits are.
2110    fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
2111        self.prs_for_branch(branch)
2112            .iter()
2113            .any(|pr| self.pr_head_holds(pr.number, refname, base))
2114    }
2115
2116    fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
2117        let head = format!("refs/spar/pr-head/{number}");
2118        let refspec = format!("+refs/pull/{number}/head:{head}");
2119        if self.git(&["fetch", "origin", &refspec]).is_err() {
2120            return false;
2121        }
2122        let held = self.commits_held_by(refname, base, &head);
2123        self.git_try(&["update-ref", "-d", &head]);
2124        held
2125    }
2126
2127    pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
2128        let argv = vec![
2129            "git".to_string(),
2130            "merge-base".to_string(),
2131            "--is-ancestor".to_string(),
2132            older.to_string(),
2133            newer.to_string(),
2134        ];
2135        let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
2136        match out.code {
2137            0 => Ok(true),
2138            1 => Ok(false),
2139            _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
2140        }
2141    }
2142
2143    fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
2144        let head = format!("refs/spar/pr-head/{number}");
2145        let refspec = format!("+refs/pull/{number}/head:{head}");
2146        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2147            spar_err!(
2148                "could not verify the immutable head of PR #{number}: {}",
2149                e.last_line()
2150            )
2151        })?;
2152        let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
2153        self.git_try(&["update-ref", "-d", &head]);
2154        held
2155    }
2156
2157    fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
2158        let text = self.gh(&[
2159            "pr",
2160            "list",
2161            "--head",
2162            branch,
2163            "--state",
2164            "all",
2165            "--json",
2166            "number,url,title",
2167        ])?;
2168        serde_json::from_str(text.trim())
2169            .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
2170    }
2171
2172    fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
2173        let branch_ref = format!("refs/heads/{branch}");
2174        if record.kind == "pr" {
2175            return self.pr_head_contains_checked(record.number, &branch_ref);
2176        }
2177        let prs = self.branch_prs_checked(branch)?;
2178        if prs.is_empty() {
2179            return Ok(false);
2180        }
2181        for pr in prs {
2182            if self.pr_head_contains_checked(pr.number, &branch_ref)? {
2183                return Ok(true);
2184            }
2185        }
2186        Ok(false)
2187    }
2188
2189    fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
2190        let local_ref = format!("refs/heads/{branch}");
2191        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2192            return Ok(true);
2193        }
2194        let oid = self
2195            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2196            .trim()
2197            .to_string();
2198        let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
2199        if !durable_tip {
2200            let remote_ref = format!("refs/heads/{branch}");
2201            let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
2202            durable_tip = remote.lines().any(|line| {
2203                line.split_whitespace()
2204                    .next()
2205                    .is_some_and(|remote_oid| remote_oid == oid)
2206            });
2207        }
2208        if !durable_tip {
2209            if let Some(record) = self.known_branches().get(branch) {
2210                durable_tip = self.branch_is_preserved_checked(branch, record)?;
2211            }
2212        }
2213        if !durable_tip {
2214            return Ok(false);
2215        }
2216        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2217    }
2218
2219    /// Delete a branch only if its exact current tip and reflog are still safe.
2220    /// The expected old value makes a concurrent ref update fail instead of
2221    /// deleting work that appeared after the preservation check.
2222    fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
2223        let local_ref = format!("refs/heads/{branch}");
2224        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2225            return Ok(true);
2226        }
2227        let expected = self
2228            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2229            .trim()
2230            .to_string();
2231        if !self.branch_deletion_is_safe(branch)? {
2232            return Ok(false);
2233        }
2234        let checked_out = self
2235            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
2236            .lines()
2237            .any(|line| line == format!("branch {local_ref}"));
2238        if checked_out {
2239            return Ok(false);
2240        }
2241        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2242        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2243    }
2244
2245    fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
2246        let local_ref = review_ref(number);
2247        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2248            return Ok(true);
2249        }
2250        let oid = self
2251            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2252            .trim()
2253            .to_string();
2254        if !self.pr_head_contains_checked(number, &local_ref)? {
2255            return Ok(false);
2256        }
2257        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2258    }
2259
2260    fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
2261        let local_ref = review_ref(number);
2262        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2263            return Ok(true);
2264        }
2265        let expected = self
2266            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2267            .trim()
2268            .to_string();
2269        if !self.review_ref_deletion_is_safe(number)? {
2270            return Ok(false);
2271        }
2272        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2273        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2274    }
2275
2276    /// Whether `other` already contains every commit `branch` has beyond
2277    /// `base`. False when either ref fails to resolve, so a ref that is not
2278    /// there cannot vouch for anything.
2279    pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
2280        let range = format!("{}..{branch}", self.base_ref(&self.root, base));
2281        self.git_try(&["rev-list", "--count", &range, "--not", other])
2282            .trim()
2283            == "0"
2284    }
2285
2286    pub fn worktree_remove(&self, issue: i64) -> bool {
2287        let path = self.worktree_path(&format!("issue-{issue}"));
2288        match self.remove_worktree_at(&path) {
2289            Ok(removed) => removed,
2290            Err(error) => {
2291                logdim!(
2292                    "kept {} because removal did not reach a confirmed quiet point: {}",
2293                    path.display(),
2294                    error.last_line()
2295                );
2296                false
2297            }
2298        }
2299    }
2300
2301    /// Verify both the common Git directory and the worktree top level.
2302    ///
2303    /// A stale worktree entry is not ownership proof. An unrelated repository
2304    /// can later occupy the same path and must survive cleanup.
2305    fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
2306        let wanted = std::fs::canonicalize(path)
2307            .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
2308        // Every SPAR worktree path is built from the canonical repository root.
2309        // A different canonical path therefore means the final component or
2310        // one of its parents is a symlink. Passing that alias to `git worktree
2311        // remove` can delete the worktree at its real target.
2312        if wanted != path {
2313            return Ok(false);
2314        }
2315        let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
2316            let raw = PathBuf::from(value.trim());
2317            let joined = if raw.is_absolute() {
2318                raw
2319            } else {
2320                cwd.join(raw)
2321            };
2322            std::fs::canonicalize(&joined)
2323                .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
2324        };
2325        let expected =
2326            self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
2327        let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
2328        let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
2329        let expected = resolve(&self.root, &expected)?;
2330        let actual = resolve(path, &actual)?;
2331        let top = resolve(path, &top)?;
2332        Ok(expected == actual && top == wanted)
2333    }
2334
2335    /// Remove only a worktree that belongs to this repository.
2336    ///
2337    /// The path sits under a predictable directory, but that does not establish
2338    /// ownership. A clean independent repository at the same path must survive
2339    /// even when `git worktree remove` rejects it.
2340    fn remove_worktree_at_with_force(
2341        &self,
2342        path: &Path,
2343        force: bool,
2344        disposable: Disposable,
2345    ) -> Result<bool> {
2346        let existed = path.exists();
2347        if path.exists() {
2348            match self.worktree_belongs_to_repo(path) {
2349                Ok(true) => {}
2350                Ok(false) => {
2351                    logdim!(
2352                        "kept {} because it is not a worktree owned by this repository",
2353                        path.display()
2354                    );
2355                    return Ok(false);
2356                }
2357                Err(e) => {
2358                    logdim!(
2359                        "kept {} because its worktree ownership could not be verified: {}",
2360                        path.display(),
2361                        e.last_line()
2362                    );
2363                    return Ok(false);
2364                }
2365            }
2366            if !force {
2367                match self.has_recoverable_work(path, disposable) {
2368                    Ok(true) => {
2369                        logdim!(
2370                            "kept {} because it contains recoverable files or repository state",
2371                            path.display()
2372                        );
2373                        return Ok(false);
2374                    }
2375                    Err(e) => {
2376                        logdim!(
2377                            "kept {} because its recoverable state could not be checked: {}",
2378                            path.display(),
2379                            e.last_line()
2380                        );
2381                        return Ok(false);
2382                    }
2383                    Ok(false) => {}
2384                }
2385            }
2386        }
2387        // Git refuses to remove a worktree holding any untracked file, which is
2388        // the same question SPAR has just answered in more detail: under
2389        // `ReviewOutput` the checks above have already established that nothing
2390        // there is worth keeping. `--force` here is that answer being carried
2391        // out, not the check being skipped, which is what `force` means.
2392        let path_str = path.display().to_string();
2393        let command_ok = if force || disposable == Disposable::ReviewOutput {
2394            self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
2395        } else {
2396            self.git_try_without_automation(&["worktree", "remove", &path_str])?
2397        };
2398        Ok((command_ok || !existed) && !path.exists())
2399    }
2400
2401    fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
2402        self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
2403    }
2404
2405    /// Remove a review checkout that is about to be rebuilt from origin.
2406    fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
2407        self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
2408    }
2409
2410    /// Force removal is reserved for the explicit `clean --all` path.
2411    fn remove_worktree_at_force(&self, path: &Path) -> bool {
2412        match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
2413            Ok(removed) => removed,
2414            Err(error) => {
2415                logdim!(
2416                    "kept {} because removal did not reach a confirmed quiet point: {}",
2417                    path.display(),
2418                    error.last_line()
2419                );
2420                false
2421            }
2422        }
2423    }
2424
2425    /// Remove a worktree only after a caller has verified it is unchanged.
2426    ///
2427    /// There is deliberately no force fallback, so Git can still refuse a
2428    /// removal if tracked or non-ignored work appears after the final check.
2429    fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
2430        if path.exists() && !self.worktree_belongs_to_repo(path)? {
2431            bail!(
2432                "{} is not a worktree owned by this repository, so it was kept",
2433                path.display()
2434            );
2435        }
2436        if path.exists() && self.has_recoverable_work(path, disposable)? {
2437            bail!(
2438                "the verified worktree at {} contains recoverable files or repository state. It \
2439                 was kept.",
2440                path.display()
2441            );
2442        }
2443        let path_str = path.display().to_string();
2444        self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
2445            .map_err(|e| {
2446                e.with_message(format!(
2447                    "could not remove the verified worktree at {}: {}. It was kept.",
2448                    path.display(),
2449                    e.last_line()
2450                ))
2451            })?;
2452        Ok(!path.exists())
2453    }
2454
2455    fn refuse_dirty_worktree(
2456        &self,
2457        path: &Path,
2458        label: &str,
2459        disposable: Disposable,
2460    ) -> Result<()> {
2461        if !path.is_dir() {
2462            return Ok(());
2463        }
2464        let has_files = std::fs::read_dir(path)
2465            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2466            .next()
2467            .is_some();
2468        let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
2469            spar_err!(
2470                "could not verify whether the existing {label} at {} belongs to this repository, \
2471                 so it was kept: {}",
2472                path.display(),
2473                e.last_line()
2474            )
2475        })?;
2476        if !owned {
2477            if has_files {
2478                bail!(
2479                    "the existing {label} at {} is not a worktree owned by \
2480                     this repository. Refusing to remove it.",
2481                    path.display()
2482                );
2483            }
2484            return Ok(());
2485        }
2486        if !path.join(".git").exists() {
2487            if has_files {
2488                bail!(
2489                    "the existing {label} at {} is not a readable Git worktree and is not empty. \
2490                     Refusing to remove it.",
2491                    path.display()
2492                );
2493            }
2494            return Ok(());
2495        }
2496        let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2497            spar_err!(
2498                "could not verify whether the existing {label} at {} is clean, so it was kept: \
2499                 {}",
2500                path.display(),
2501                e.last_line()
2502            )
2503        })?;
2504        if dirty {
2505            bail!(
2506                "the existing {label} contains uncommitted changes or ignored files at {}. \
2507                 Rebuilding it would delete those files.\nCommit or recover them before running this \
2508                 command again, or use `spar clean --all` if they are not needed.",
2509                path.display()
2510            );
2511        }
2512        Ok(())
2513    }
2514
2515    /// Check an existing PR branch out into an isolated worktree, from
2516    /// wherever `target` says the branch is published.
2517    pub fn worktree_for_pr(&self, pr: &PrView, target: &PushTarget) -> Result<(PathBuf, String)> {
2518        let head = pr.head_ref_name.clone();
2519        if head.trim().is_empty() {
2520            bail!("PR #{} has no head branch to check out", pr.number);
2521        }
2522        let path = self.worktree_path(&format!("pr-{}", pr.number));
2523        let local = self.branch_for_pr(pr.number);
2524
2525        let unfetched = |e: crate::error::SparError| {
2526            spar_err!(
2527                "could not fetch the branch behind PR #{}: {}",
2528                pr.number,
2529                e.last_line()
2530            )
2531        };
2532        // What the worktree starts from, the ref behind it, and what to call
2533        // it when saying so.
2534        let (start, start_ref, shown) = match target {
2535            PushTarget::Origin => {
2536                self.git(&["fetch", "origin", &head]).map_err(unfetched)?;
2537                let start = format!("origin/{head}");
2538                (start.clone(), format!("refs/remotes/origin/{head}"), start)
2539            }
2540            // A fork has no remote here to track it, so its branch is
2541            // fetched into a ref of spar's own.
2542            PushTarget::Fork(fork) => {
2543                let start_ref = format!("refs/spar/fork/{head}");
2544                let refspec = format!("+refs/heads/{head}:{start_ref}");
2545                self.git(&["fetch", "--no-tags", &fork.url, &refspec])
2546                    .map_err(unfetched)?;
2547                (start_ref.clone(), start_ref, target.describe(&head))
2548            }
2549        };
2550        let local_ref = format!("refs/heads/{local}");
2551        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2552            let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2553            if unpushed > 0 {
2554                bail!(
2555                    "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2556                     on {shown}. Rebuilding it would delete their branch.\nInspect the worktree at \
2557                     {} and push or recover those commits before running this command again.",
2558                    pr.number,
2559                    path.display()
2560                );
2561            }
2562        }
2563        self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2564        self.refuse_dirty_worktree(
2565            &path,
2566            &format!("worktree for PR #{}", pr.number),
2567            Disposable::BuildOutput,
2568        )?;
2569        if !self.branch_deletion_is_safe(&local)? {
2570            bail!(
2571                "the existing branch {local} has a tip or reflog-only commit that no surviving \
2572                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2573                 before retrying."
2574            );
2575        }
2576        if !self.remove_worktree_at(&path)? {
2577            bail!(
2578                "the existing worktree for PR #{} could not be removed safely. Its branch was \
2579                 kept.",
2580                pr.number
2581            );
2582        }
2583        if !self.delete_branch_if_safe(&local)? {
2584            bail!(
2585                "the existing branch {local} changed or remained checked out while the PR \
2586                 worktree was being rebuilt. It was kept."
2587            );
2588        }
2589
2590        let path_str = path.display().to_string();
2591        self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2592        self.record_branch(&local, "pr", pr.number);
2593        self.set_up_worktree(&path, &format!("worktree for PR #{}", pr.number))?;
2594        Ok((path, head))
2595    }
2596
2597    /// A retry needs a fresh checkout, not permission to delete ignored data.
2598    /// Keep that data and its branch when all commits are already published.
2599    fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2600        if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2601            return Ok(());
2602        }
2603        let current =
2604            self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2605        if current.trim() != branch
2606            || self.has_uncommitted_changes(path)?
2607            || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2608        {
2609            return Ok(());
2610        }
2611        static NEXT: AtomicUsize = AtomicUsize::new(0);
2612        let (name, retained) = loop {
2613            let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2614            let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2615            let retained = self.worktree_path(&name);
2616            if !retained
2617                .try_exists()
2618                .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2619                && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2620            {
2621                break (name, retained);
2622            }
2623        };
2624        // Neither command overwrites an existing destination. The branch rename
2625        // preserves its reflog, and the worktree move preserves every file.
2626        self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2627        if let Err(error) = self.git_at_without_automation(
2628            &self.root,
2629            &[
2630                "worktree",
2631                "move",
2632                &path.display().to_string(),
2633                &retained.display().to_string(),
2634            ],
2635        ) {
2636            // A locked worktree or one with submodules cannot always be moved.
2637            // Restore the branch name so a failed move does not strand a retry.
2638            let rollback =
2639                self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2640            let detail = rollback
2641                .err()
2642                .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2643                .unwrap_or_default();
2644            bail!(
2645                "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2646                retained.display(),
2647                error.last_line()
2648            );
2649        }
2650        logdim!(
2651            "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2652            retained.display()
2653        );
2654        Ok(())
2655    }
2656
2657    /// Check a pull request's head out read only, detached, with no branch.
2658    ///
2659    /// Fetches `refs/pull/N/head`, which GitHub serves for every pull request
2660    /// including one from a fork whose branch is not in this repository at all.
2661    /// That is what makes reviewing an outside contribution possible when
2662    /// pushing to it is not.
2663    ///
2664    /// Detached on purpose. Review only mode has nothing to push, and a branch
2665    /// would only invite something to try.
2666    pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2667        let path = self.worktree_path(&format!("review-{number}"));
2668        let local_ref = review_ref(number);
2669        let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2670
2671        self.refuse_review_worktree_changes(number)?;
2672
2673        if let Some(parent) = path.parent() {
2674            std::fs::create_dir_all(parent)
2675                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2676        }
2677        // Removed before the fetch, while `local_ref` still holds the head
2678        // this checkout is at. A force push moves the ref off that commit,
2679        // and the checkout's reflog would then name a commit no ref holds,
2680        // which the removal check rightly keeps as local work.
2681        if !self.remove_review_worktree_at(&path)? {
2682            bail!(
2683                "the existing review worktree for PR #{number} could not be removed safely. Its \
2684                 reference was kept."
2685            );
2686        }
2687
2688        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2689            spar_err!(
2690                "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2691                 every pull request, so this usually means the number is wrong or `origin` does \
2692                 not point at the repository the PR is on.",
2693                e.last_line()
2694            )
2695        })?;
2696
2697        let path_str = path.display().to_string();
2698        self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2699        self.set_up_worktree(&path, &format!("review worktree for PR #{number}"))?;
2700        Ok(path)
2701    }
2702
2703    fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2704        let path = self.worktree_path(&format!("review-{number}"));
2705        if !path.is_dir() {
2706            return Ok(());
2707        }
2708        let local_ref = review_ref(number);
2709        if !self.worktree_belongs_to_repo(&path)? {
2710            return Ok(());
2711        }
2712        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2713            bail!(
2714                "the existing review worktree for PR #{number} has no recorded head at \
2715                 {local_ref}. Refusing to rebuild {}.",
2716                path.display()
2717            );
2718        }
2719        let worktree_head = self.head_oid_checked(&path)?;
2720        let recorded_head = self
2721            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2722            .trim()
2723            .to_string();
2724        if worktree_head != recorded_head {
2725            bail!(
2726                "the existing review worktree for PR #{number} has a local commit that is not on \
2727                 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2728                 {} before retrying.",
2729                path.display()
2730            );
2731        }
2732        self.report_recovery_markers(&path);
2733        self.refuse_dirty_worktree(
2734            &path,
2735            &format!("review worktree for PR #{number}"),
2736            Disposable::ReviewOutput,
2737        )?;
2738        Ok(())
2739    }
2740
2741    /// Read out what an earlier run refused to explain away, before rebuilding
2742    /// over it.
2743    ///
2744    /// The marker is the only record of why a call's answer was thrown out, and
2745    /// a review checkout is rebuilt on the next run. Saying it here is what
2746    /// keeps the file from being the thing that quietly blocks the command
2747    /// instead.
2748    fn report_recovery_markers(&self, path: &Path) {
2749        let Ok(entries) = std::fs::read_dir(path) else {
2750            return;
2751        };
2752        for entry in entries.flatten() {
2753            let name = entry.file_name();
2754            let Some(name) = name.to_str() else { continue };
2755            if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2756                continue;
2757            }
2758            match std::fs::read_to_string(entry.path()) {
2759                Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2760                Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2761            }
2762        }
2763    }
2764
2765    /// A checkout of HEAD for calls that only read, made fresh for them.
2766    ///
2767    /// With `[loop] worktrees` off the calls stay in the shared checkout,
2768    /// which is what that setting asks of every call, and so they do when
2769    /// nothing is committed yet, since there is no commit to check out.
2770    pub fn read_checkout(&self, label: &str) -> Result<ReadCheckout<'_>> {
2771        let shared = || ReadCheckout {
2772            repo: self,
2773            path: self.root.clone(),
2774            checkpoint: None,
2775        };
2776        if !self.isolate_reads {
2777            return Ok(shared());
2778        }
2779        let Some(head) = resolve_optional_commit(&self.root, "HEAD")? else {
2780            return Ok(shared());
2781        };
2782        static NEXT: AtomicUsize = AtomicUsize::new(0);
2783        let path = loop {
2784            let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2785            let path = self.worktree_path(&format!(
2786                "{READ_CHECKOUT_PREFIX}{}-{serial}",
2787                std::process::id()
2788            ));
2789            if !path
2790                .try_exists()
2791                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2792            {
2793                break path;
2794            }
2795        };
2796        if let Some(parent) = path.parent() {
2797            std::fs::create_dir_all(parent)
2798                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2799        }
2800        let path_str = path.display().to_string();
2801        self.git(&["worktree", "add", "--detach", &path_str, &head])
2802            .map_err(|e| {
2803                spar_err!(
2804                    "could not check HEAD out for the {label}: {}",
2805                    e.last_line()
2806                )
2807            })?;
2808        let ready = self
2809            .set_up_worktree(&path, &format!("read-only checkout for the {label}"))
2810            .and_then(|()| self.worktree_checkpoint(&path));
2811        match ready {
2812            Ok(checkpoint) => Ok(ReadCheckout {
2813                repo: self,
2814                path,
2815                checkpoint: Some(checkpoint),
2816            }),
2817            Err(e) => {
2818                // No call has run in it yet, so nothing in it is anybody's.
2819                self.discard_read_checkout(&path);
2820                Err(e)
2821            }
2822        }
2823    }
2824
2825    /// Remove a read checkout once nothing in it has changed since setup.
2826    ///
2827    /// What can be left after that is ignored output from calls that were
2828    /// allowed to leave it, in a checkout nobody else works in, so it goes
2829    /// with the checkout. A recovery marker means a call's answer was already
2830    /// refused over what it did there, and that error said where to look.
2831    fn release_read_checkout(&self, path: &Path, checkpoint: &WorktreeCheckpoint) {
2832        if self.keep_worktrees {
2833            logdim!("kept the read-only checkout at {}", path.display());
2834            return;
2835        }
2836        if has_recovery_marker(path) {
2837            logdim!(
2838                "kept the read-only checkout at {} for recovery",
2839                path.display()
2840            );
2841            return;
2842        }
2843        if let Err(e) = self.require_unchanged_worktree(path, checkpoint, "read-only checkout") {
2844            logwarn!("{e}");
2845            return;
2846        }
2847        self.discard_read_checkout(path);
2848    }
2849
2850    fn discard_read_checkout(&self, path: &Path) {
2851        match self.remove_worktree_at_checked(path, Disposable::ReviewOutput) {
2852            Ok(true) => {}
2853            Ok(false) => logdim!(
2854                "kept the read-only checkout at {} because it was still there after removal",
2855                path.display()
2856            ),
2857            Err(e) => logdim!(
2858                "kept the read-only checkout at {}: {}",
2859                path.display(),
2860                e.last_line()
2861            ),
2862        }
2863    }
2864
2865    /// A worktree for one part of a split, on a new branch off `start`.
2866    ///
2867    /// `start` is the base branch for independent parts and the previous part's
2868    /// branch for stacked ones, which is the only difference between the two
2869    /// shapes at this level.
2870    ///
2871    /// The branch is whatever name was free, which is why it is returned rather
2872    /// than derived by the caller. Splitting the same pull request a second
2873    /// time would otherwise target the branch behind the first run's pull
2874    /// request. Split pushes are create-only and would refuse that target, but
2875    /// a repeated split still needs distinct branches rather than a name that
2876    /// can never be created.
2877    pub fn worktree_for_split(
2878        &self,
2879        parent: i64,
2880        index: usize,
2881        start: &str,
2882    ) -> Result<(PathBuf, String)> {
2883        let slot = self.free_split_slot(parent, index)?;
2884        let branch = format!("{}{slot}", self.branch_prefix);
2885        let path = self.worktree_path(&slot);
2886
2887        if let Some(dir) = path.parent() {
2888            std::fs::create_dir_all(dir)
2889                .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2890        }
2891        self.refuse_dirty_worktree(
2892            &path,
2893            &format!("worktree for part {index} of PR #{parent}"),
2894            Disposable::BuildOutput,
2895        )?;
2896        // The name is free, so there is no branch to delete. A directory can
2897        // still be in the way, left by a worktree that was pruned from git's
2898        // records without being removed from disk.
2899        if !self.remove_worktree_at(&path)? {
2900            bail!(
2901                "the existing worktree for part {index} of PR #{parent} could not be removed \
2902                 safely. No branch was created."
2903            );
2904        }
2905
2906        let path_str = path.display().to_string();
2907        self.git(&["worktree", "add", "-b", &branch, &path_str, start])
2908            .map_err(|e| {
2909                spar_err!(
2910                    "could not create a worktree for part {index} of #{parent}. {}",
2911                    e.last_line()
2912                )
2913            })?;
2914        // Recorded before anything else can fail. An unrecorded branch is one
2915        // `prune_branches` will never remove.
2916        self.record_branch(&branch, "split", parent);
2917        self.set_up_worktree(&path, &format!("worktree for part {index} of PR #{parent}"))?;
2918        Ok((path, branch))
2919    }
2920
2921    /// The first part branch nothing is already sitting on.
2922    ///
2923    /// Origin as well as local, because a part's branch outlives the local one:
2924    /// a second split of the same pull request finds its own earlier branches
2925    /// deleted here but alive on origin, where the pull requests that reviewed
2926    /// them still point at them.
2927    fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2928        for attempt in 1..=SPLIT_SLOTS {
2929            let slot = split_slot(parent, index, attempt);
2930            let branch = format!("{}{slot}", self.branch_prefix);
2931            self.git_try(&["fetch", "origin", &branch]);
2932            if !self.rev_exists(&self.root, &branch)
2933                && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2934            {
2935                return Ok(slot);
2936            }
2937        }
2938        bail!(
2939            "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2940             names are all taken. Inspect the existing branches and child pull requests. Finish \
2941             recording the earlier split, or remove every retained local worktree and branch, \
2942             child pull request, and remote split branch before starting over.",
2943            self.branch_for_split(parent, index)
2944        )
2945    }
2946
2947    /// Whether a previous attempt pushed any branch for this split.
2948    ///
2949    /// The parent comment is the normal retry marker. A branch is the fallback
2950    /// when that comment or the pull request creation failed after the push.
2951    /// Reading origin directly makes the guard survive a fresh clone.
2952    pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2953        let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2954        Ok(!self
2955            .git(&["ls-remote", "--heads", "origin", &pattern])?
2956            .trim()
2957            .is_empty())
2958    }
2959
2960    /// Throw one part away: its worktree, its branch, and its record.
2961    ///
2962    /// For a part that would not stand on its own. Nothing has been pushed at
2963    /// that point, so this leaves no trace anywhere but the log. Takes what
2964    /// `worktree_for_split` returned, since the name it settled on is not
2965    /// derivable from the parent and the index.
2966    pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2967        match self.branch_deletion_is_safe(branch) {
2968            Ok(true) => {}
2969            Ok(false) => {
2970                logdim!(
2971                    "kept {branch} and {} because no surviving ref preserves its tip",
2972                    dir.display()
2973                );
2974                return;
2975            }
2976            Err(error) => {
2977                logdim!(
2978                    "kept {branch} and {} because preservation could not be verified: {}",
2979                    dir.display(),
2980                    error.last_line()
2981                );
2982                return;
2983            }
2984        }
2985        match self.remove_worktree_at(dir) {
2986            Ok(true) => match self.delete_branch_if_safe(branch) {
2987                Ok(true) => self.forget_branch(branch),
2988                Ok(false) => {
2989                    logdim!("kept {branch} because its tip or reflog changed before deletion")
2990                }
2991                Err(error) => logdim!(
2992                    "kept {branch} because deletion safety could not be rechecked: {}",
2993                    error.last_line()
2994                ),
2995            },
2996            Ok(false) => {}
2997            Err(error) => logdim!(
2998                "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
2999                dir.display(),
3000                error.last_line()
3001            ),
3002        }
3003    }
3004
3005    /// Discard one exact mechanical slice that the split workflow just made.
3006    ///
3007    /// Unlike ordinary release, this intentionally removes an unpushed commit.
3008    /// The caller supplies the exact disposable tip, and every file, worktree,
3009    /// ownership, and ref check must still match before anything is removed.
3010    pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
3011        let record = self.known_branches().get(branch).cloned();
3012        if record.is_none_or(|record| record.kind != "split") {
3013            logdim!("kept {branch} because no split branch record proves ownership");
3014            return false;
3015        }
3016        let local_ref = format!("refs/heads/{branch}");
3017        let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
3018            Ok(value) => value.trim().to_string(),
3019            Err(error) => {
3020                logdim!(
3021                    "kept {branch} because its tip could not be checked: {}",
3022                    error.last_line()
3023                );
3024                return false;
3025            }
3026        };
3027        if expected != disposable_head {
3028            logdim!("kept {branch} because it moved beyond the disposable slice");
3029            return false;
3030        }
3031        match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
3032            Ok(true) => {}
3033            Ok(false) => {
3034                logdim!(
3035                    "kept {branch} because its reflog contains work outside the disposable slice"
3036                );
3037                return false;
3038            }
3039            Err(error) => {
3040                logdim!(
3041                    "kept {branch} because its reflog could not be checked: {}",
3042                    error.last_line()
3043                );
3044                return false;
3045            }
3046        }
3047        match self.head_oid_checked(dir) {
3048            Ok(head) if head == disposable_head => {}
3049            Ok(_) => {
3050                logdim!(
3051                    "kept {branch} and {} because the worktree moved beyond the disposable slice",
3052                    dir.display()
3053                );
3054                return false;
3055            }
3056            Err(error) => {
3057                logdim!(
3058                    "kept {branch} and {} because its head could not be checked: {}",
3059                    dir.display(),
3060                    error.last_line()
3061                );
3062                return false;
3063            }
3064        }
3065        match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
3066            Ok(true) => {}
3067            Ok(false) => return false,
3068            Err(error) => {
3069                logdim!(
3070                    "kept {branch} and {} because the disposable slice could not be verified: {}",
3071                    dir.display(),
3072                    error.last_line()
3073                );
3074                return false;
3075            }
3076        }
3077        if let Err(error) =
3078            self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
3079        {
3080            logdim!(
3081                "kept {branch} because its exact disposable tip could not be deleted: {}",
3082                error.last_line()
3083            );
3084            return false;
3085        }
3086        match self.exact_ref_exists_checked(&self.root, &local_ref) {
3087            Ok(false) => {
3088                self.forget_branch(branch);
3089                true
3090            }
3091            Ok(true) => {
3092                logdim!("kept {branch} because its ref still exists after deletion");
3093                false
3094            }
3095            Err(error) => {
3096                logdim!(
3097                    "kept the branch record for {branch} because deletion could not be verified: {}",
3098                    error.last_line()
3099                );
3100                false
3101            }
3102        }
3103    }
3104
3105    pub fn release_review_worktree(&self, number: i64) {
3106        let path = self.worktree_path(&format!("review-{number}"));
3107        match self.review_ref_deletion_is_safe(number) {
3108            Ok(true) => {}
3109            Ok(false) => {
3110                logdim!(
3111                    "kept {} because no surviving ref preserves its review history",
3112                    path.display()
3113                );
3114                return;
3115            }
3116            Err(error) => {
3117                logdim!(
3118                    "kept {} because review history could not be verified: {}",
3119                    path.display(),
3120                    error.last_line()
3121                );
3122                return;
3123            }
3124        }
3125        match self.remove_worktree_at(&path) {
3126            Ok(true) => match self.delete_review_ref_if_safe(number) {
3127                Ok(true) => {}
3128                Ok(false) => logdim!(
3129                    "kept {} because its review history changed before deletion",
3130                    review_ref(number)
3131                ),
3132                Err(error) => logdim!(
3133                    "kept {} because deletion safety could not be rechecked: {}",
3134                    review_ref(number),
3135                    error.last_line()
3136                ),
3137            },
3138            Ok(false) => {}
3139            Err(error) => logdim!(
3140                "kept {} because removal did not reach a confirmed quiet point: {}",
3141                path.display(),
3142                error.last_line()
3143            ),
3144        }
3145    }
3146
3147    /// Release a read-only review checkout only when every observed part of
3148    /// its Git state still matches the checkpoint captured before the calls.
3149    pub(crate) fn release_review_worktree_checked(
3150        &self,
3151        number: i64,
3152        checkpoint: &WorktreeCheckpoint,
3153    ) -> Result<()> {
3154        let path = self.worktree_path(&format!("review-{number}"));
3155        self.require_unchanged_worktree(
3156            &path,
3157            checkpoint,
3158            &format!("review worktree for PR #{number}"),
3159        )?;
3160        if !self.review_ref_deletion_is_safe(number)? {
3161            bail!(
3162                "the review reference for PR #{number} has reflog-only recovery history. The \
3163                 worktree and reference were kept."
3164            );
3165        }
3166        if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
3167            bail!(
3168                "the verified review worktree at {} could not be removed, so its reference was \
3169                 kept",
3170                path.display()
3171            );
3172        }
3173        if !self.delete_review_ref_if_safe(number)? {
3174            bail!(
3175                "the review reference for PR #{number} changed before deletion. The reference was \
3176                 kept."
3177            );
3178        }
3179        Ok(())
3180    }
3181
3182    pub fn release_pr_worktree(&self, number: i64) -> bool {
3183        let path = self.worktree_path(&format!("pr-{number}"));
3184        let local = self.branch_for_pr(number);
3185        match self.branch_deletion_is_safe(&local) {
3186            Ok(true) => {}
3187            Ok(false) => {
3188                logdim!(
3189                    "kept {local} and {} because no surviving ref preserves its tip",
3190                    path.display()
3191                );
3192                return false;
3193            }
3194            Err(error) => {
3195                logdim!(
3196                    "kept {local} and {} because preservation could not be verified: {}",
3197                    path.display(),
3198                    error.last_line()
3199                );
3200                return false;
3201            }
3202        }
3203        match self.remove_worktree_at(&path) {
3204            Ok(true) => match self.delete_branch_if_safe(&local) {
3205                Ok(true) => {
3206                    self.forget_branch(&local);
3207                    true
3208                }
3209                Ok(false) => {
3210                    logdim!("kept {local} because its tip or reflog changed before deletion");
3211                    false
3212                }
3213                Err(error) => {
3214                    logdim!(
3215                        "kept {local} because deletion safety could not be rechecked: {}",
3216                        error.last_line()
3217                    );
3218                    false
3219                }
3220            },
3221            Ok(false) => false,
3222            Err(error) => {
3223                logdim!(
3224                    "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
3225                    path.display(),
3226                    error.last_line()
3227                );
3228                false
3229            }
3230        }
3231    }
3232
3233    // -- branch state -----------------------------------------------------
3234
3235    /// What to diff against: the remote tracking branch when it resolves, the
3236    /// local branch when it does not.
3237    ///
3238    /// This is not a nicety. Every "did the agent do anything" check hangs off
3239    /// this ref, and `git log` against a ref that does not exist fails silently
3240    /// and reads as "no commits". A checkout whose `origin/main` was never
3241    /// fetched would report every implementation as abandoned and throw the
3242    /// work away.
3243    pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
3244        let remote = format!("origin/{base}");
3245        if self.rev_exists(cwd, &remote) {
3246            return remote;
3247        }
3248        if self.rev_exists(cwd, base) {
3249            logdim!("origin/{base} does not resolve, comparing against local {base}");
3250            return base.to_string();
3251        }
3252        logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
3253        remote
3254    }
3255
3256    fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
3257        let spec = format!("{refname}^{{commit}}");
3258        !self
3259            .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
3260            .trim()
3261            .is_empty()
3262    }
3263
3264    pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
3265        let range = format!("{}..HEAD", self.base_ref(cwd, base));
3266        !self
3267            .git_try_at(Some(cwd), &["log", &range, "--oneline"])
3268            .trim()
3269            .is_empty()
3270    }
3271
3272    fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
3273        let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
3274        Ok(found.lines().any(|line| line.trim() == refname))
3275    }
3276
3277    fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
3278        let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
3279        count.trim().parse::<usize>().map_err(|e| {
3280            spar_err!(
3281                "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
3282                count.trim()
3283            )
3284        })
3285    }
3286
3287    pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
3288        let remote = format!("refs/remotes/origin/{base}");
3289        if self.exact_ref_exists_checked(cwd, &remote)? {
3290            return Ok(remote);
3291        }
3292        let local = format!("refs/heads/{base}");
3293        if self.exact_ref_exists_checked(cwd, &local)? {
3294            return Ok(local);
3295        }
3296        bail!("neither origin/{base} nor local branch {base} resolves")
3297    }
3298
3299    pub(crate) fn commit_count_checked(
3300        &self,
3301        cwd: &Path,
3302        refname: &str,
3303        base: &str,
3304    ) -> Result<usize> {
3305        let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
3306        let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
3307        count.trim().parse::<usize>().map_err(|e| {
3308            spar_err!(
3309                "git returned an invalid commit count for {range}: {:?} ({e})",
3310                count.trim()
3311            )
3312        })
3313    }
3314
3315    pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
3316        Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
3317    }
3318
3319    pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
3320        let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
3321        let head = head.trim().to_string();
3322        if head.is_empty() {
3323            bail!("git returned an empty HEAD for {}", cwd.display());
3324        }
3325        Ok(head)
3326    }
3327
3328    /// Whether a recorded SPAR branch's exact tip is retained by a pull request.
3329    ///
3330    /// Pull request head refs remain available after close or merge, so this is
3331    /// stronger than requiring an open pull request or a live remote branch.
3332    pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
3333        let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
3334        self.local_branch_is_preserved(branch.trim())
3335    }
3336
3337    /// Whether a recorded local branch's exact tip is retained by its pull
3338    /// request head, regardless of which branch is currently checked out.
3339    pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
3340        let known = self.known_branches();
3341        let Some(record) = known.get(branch) else {
3342            return Ok(false);
3343        };
3344        self.branch_is_preserved_checked(branch, record)
3345    }
3346
3347    /// Whether tracked, staged, or non-ignored untracked files are uncommitted.
3348    pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
3349        has_uncommitted_work(cwd)
3350    }
3351
3352    /// Whether removing a worktree would delete any local file Git does not
3353    /// reproduce from its commits, including ignored untracked files.
3354    fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
3355        repository_has_recoverable_work_with(cwd, true, disposable)
3356    }
3357
3358    /// Record ignored artifacts that existed before an editing call.
3359    pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
3360        let attributes = attribute_state(cwd)?;
3361        Ok(WorktreeBaseline {
3362            attributes,
3363            ignored_untracked: ignored_untracked_state(cwd)?,
3364            git_state: safe_git_state(cwd)?,
3365        })
3366    }
3367
3368    /// Capture the Git state of a checkout intended to remain read only while
3369    /// external commands inspect it.
3370    pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
3371        let attributes = attribute_state(cwd)?;
3372        Ok(WorktreeCheckpoint {
3373            path: std::fs::canonicalize(cwd)
3374                .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
3375            attributes,
3376            git_state: safe_git_state(cwd)?,
3377            ignored_untracked: ignored_untracked_state(cwd)?,
3378        })
3379    }
3380
3381    /// Require a read-only checkout to match a previously captured checkpoint.
3382    /// Any probe failure is an error because deletion cannot be proven safe.
3383    pub(crate) fn require_unchanged_worktree(
3384        &self,
3385        cwd: &Path,
3386        checkpoint: &WorktreeCheckpoint,
3387        label: &str,
3388    ) -> Result<()> {
3389        let resolved = std::fs::canonicalize(cwd).map_err(|e| {
3390            crate::error::SparError::uncertain_write(format!(
3391                "could not resolve the {label} at {} after inspection: {e}. It was kept.",
3392                cwd.display()
3393            ))
3394        })?;
3395        if resolved != checkpoint.path {
3396            return Err(uncertain_worktree_change(
3397                cwd,
3398                format!(
3399                    "the {label} moved from {} to {} during inspection. It was kept.",
3400                    checkpoint.path.display(),
3401                    resolved.display()
3402                ),
3403            ));
3404        }
3405        let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
3406            uncertain_worktree_change(
3407                cwd,
3408                format!(
3409                    "could not verify attribute files in the {label} at {}: {}. It was kept.",
3410                    cwd.display(),
3411                    e.last_line()
3412                ),
3413            )
3414        })?;
3415        if attributes != checkpoint.attributes {
3416            return Err(uncertain_worktree_change(
3417                cwd,
3418                format!(
3419                    "attribute files in the {label} at {} changed during inspection. It was \
3420                     kept for recovery.",
3421                    cwd.display()
3422                ),
3423            ));
3424        }
3425        let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
3426            uncertain_worktree_change(
3427                cwd,
3428                format!(
3429                    "could not verify the Git state of the {label} at {}: {}. It was kept.",
3430                    cwd.display(),
3431                    e.last_line()
3432                ),
3433            )
3434        })?;
3435        let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
3436            uncertain_worktree_change(
3437                cwd,
3438                format!(
3439                    "could not verify untracked files in the {label} at {}: {}. It was kept.",
3440                    cwd.display(),
3441                    e.last_line()
3442                ),
3443            )
3444        })?;
3445        let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
3446        // Said even when the inspection is accepted, for the same reason it is
3447        // said on an agent call: a path the project ignores is out of scope,
3448        // not unnoticed.
3449        note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
3450        let tracked = checkpoint.git_state.describe_difference(&git_state);
3451        let untracked = (!leavings.condemning.is_empty()).then(|| {
3452            format!(
3453                "untracked file(s) changed: {}",
3454                list_paths(&leavings.condemning)
3455            )
3456        });
3457        let Some(reason) = join_reasons(tracked, untracked) else {
3458            return Ok(());
3459        };
3460        Err(uncertain_worktree_change(
3461            cwd,
3462            format!(
3463                "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
3464                 for recovery.",
3465                cwd.display()
3466            ),
3467        ))
3468    }
3469
3470    /// Accept project-ignored files left by a successful editing call.
3471    /// Ordinary untracked files must already be represented by the commit.
3472    /// Acceptance does not authorize cleanup of the ignored files.
3473    pub(crate) fn refuse_new_ignored_files(
3474        &self,
3475        cwd: &Path,
3476        baseline: &WorktreeBaseline,
3477    ) -> Result<()> {
3478        self.allow_ignored_files(cwd, baseline).map(drop)
3479    }
3480
3481    /// Preserve unexpected output when the editing call did not finish.
3482    pub(crate) fn refuse_failed_edit_leavings(
3483        &self,
3484        cwd: &Path,
3485        baseline: &WorktreeBaseline,
3486    ) -> Result<()> {
3487        self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
3488            .map(drop)
3489    }
3490
3491    /// Reject ordinary untracked changes after a read-only call.
3492    pub(crate) fn refuse_read_only_leavings(
3493        &self,
3494        cwd: &Path,
3495        baseline: &WorktreeBaseline,
3496    ) -> Result<()> {
3497        self.refuse_changed_attributes(cwd, baseline)?;
3498        let after = ignored_untracked_state(cwd).map_err(|e| {
3499            uncertain_worktree_change(
3500                cwd,
3501                format!(
3502                    "could not verify untracked files in {} after a read-only call: {}. The \
3503                     worktree was kept for recovery.",
3504                    cwd.display(),
3505                    e.last_line()
3506                ),
3507            )
3508        })?;
3509        let leavings = baseline.ignored_untracked.read_only_leavings(&after);
3510        if leavings.condemning.is_empty() {
3511            return Ok(());
3512        }
3513        Err(uncertain_worktree_change(
3514            cwd,
3515            format!(
3516                "the read-only call left untracked file(s) in {} that the project does not \
3517                 ignore: {}. The worktree was kept for recovery.",
3518                cwd.display(),
3519                list_paths(&leavings.condemning)
3520            ),
3521        ))
3522    }
3523
3524    /// The ignored paths the check let through, for one report per attempt
3525    /// rather than one per check.
3526    fn allow_ignored_files(
3527        &self,
3528        cwd: &Path,
3529        baseline: &WorktreeBaseline,
3530    ) -> Result<AllowedArtifacts> {
3531        self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
3532    }
3533
3534    fn check_new_ignored_files(
3535        &self,
3536        cwd: &Path,
3537        baseline: &WorktreeBaseline,
3538        allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
3539    ) -> Result<AllowedArtifacts> {
3540        self.refuse_changed_attributes(cwd, baseline)?;
3541        let after = ignored_untracked_state(cwd).map_err(|e| {
3542            uncertain_worktree_change(
3543                cwd,
3544                format!(
3545                    "could not verify untracked files in {} after editing: {}. The worktree was \
3546                     kept for recovery.",
3547                    cwd.display(),
3548                    e.last_line()
3549                ),
3550            )
3551        })?;
3552        let changed = baseline.ignored_untracked.changed_paths(&after);
3553        if changed.is_empty() {
3554            return Ok(AllowedArtifacts::default());
3555        }
3556        let (ignored, changed): (Vec<_>, Vec<_>) = changed
3557            .into_iter()
3558            .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
3559        if changed.is_empty() {
3560            return Ok(AllowedArtifacts::split(ignored, &after));
3561        }
3562        let listed = list_paths(&changed);
3563        Err(uncertain_worktree_change(
3564            cwd,
3565            format!(
3566                "the editing call created or changed untracked or ignored file(s) in {} that \
3567                 cannot be represented by a managed commit: {listed}. The worktree was kept for \
3568                 recovery.",
3569                cwd.display()
3570            ),
3571        ))
3572    }
3573
3574    /// Existing untracked files belong to the checkout owner, even when a call
3575    /// also produces a valid tracked change. Refuse their modification or
3576    /// deletion before accepting the tracked result. Project-ignored files
3577    /// stay outside the commit, wherever the project puts them.
3578    pub(crate) fn refuse_changed_existing_untracked(
3579        &self,
3580        cwd: &Path,
3581        baseline: &WorktreeBaseline,
3582    ) -> Result<()> {
3583        self.check_changed_existing_untracked(cwd, baseline)
3584            .map(drop)
3585    }
3586
3587    fn allow_changed_ignored_artifacts(
3588        &self,
3589        cwd: &Path,
3590        baseline: &WorktreeBaseline,
3591    ) -> Result<AllowedArtifacts> {
3592        self.check_changed_existing_untracked(cwd, baseline)
3593    }
3594
3595    fn check_changed_existing_untracked(
3596        &self,
3597        cwd: &Path,
3598        baseline: &WorktreeBaseline,
3599    ) -> Result<AllowedArtifacts> {
3600        self.refuse_changed_attributes(cwd, baseline)?;
3601        let after = ignored_untracked_state(cwd).map_err(|e| {
3602            uncertain_worktree_change(
3603                cwd,
3604                format!(
3605                    "could not verify existing untracked files in {} after editing: {}. The \
3606                     worktree was kept for recovery.",
3607                    cwd.display(),
3608                    e.last_line()
3609                ),
3610            )
3611        })?;
3612        let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3613        if changed.is_empty() {
3614            return Ok(AllowedArtifacts::default());
3615        }
3616        let (ignored, changed): (Vec<_>, Vec<_>) = changed
3617            .into_iter()
3618            .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3619        if changed.is_empty() {
3620            return Ok(AllowedArtifacts::split(ignored, &after));
3621        }
3622        let listed = list_paths(&changed);
3623        Err(uncertain_worktree_change(
3624            cwd,
3625            format!(
3626                "the editing call changed or deleted existing untracked file(s) in {}: \
3627                 {listed}. The worktree was kept for recovery.",
3628                cwd.display()
3629            ),
3630        ))
3631    }
3632
3633    /// Refuse a byte or mode change that the index did not represent.
3634    ///
3635    /// Clean filters can normalize a working file back to its existing blob,
3636    /// and index flags can hide a change from porcelain status. Comparing the
3637    /// actual tracked files on both sides keeps those bytes from being treated
3638    /// as disposable just because Git has no diff for them.
3639    pub(crate) fn refuse_unrepresented_tracked_changes(
3640        &self,
3641        cwd: &Path,
3642        baseline: &WorktreeBaseline,
3643    ) -> Result<()> {
3644        self.refuse_changed_attributes(cwd, baseline)?;
3645        let after = safe_git_state(cwd).map_err(|e| {
3646            uncertain_worktree_change(
3647                cwd,
3648                format!(
3649                    "could not verify tracked files in {} after editing: {}. The worktree was \
3650                     kept for recovery.",
3651                    cwd.display(),
3652                    e.last_line()
3653                ),
3654            )
3655        })?;
3656        let mut changed = Vec::new();
3657        let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3658            uncertain_worktree_change(
3659                cwd,
3660                format!(
3661                    "could not record untracked files before verifying transformed content in {}: \
3662                     {}. The worktree was kept for recovery.",
3663                    cwd.display(),
3664                    e.last_line()
3665                ),
3666            )
3667        })?;
3668        let mut filter_was_run = false;
3669        let mut filter_problem = None;
3670        let mut repositories: BTreeSet<PathBuf> =
3671            baseline.git_state.repositories.keys().cloned().collect();
3672        repositories.extend(after.repositories.keys().cloned());
3673        'repositories: for repository_path in repositories {
3674            let before_repository = baseline.git_state.repositories.get(&repository_path);
3675            let after_repository = after.repositories.get(&repository_path);
3676            if before_repository.is_none() || after_repository.is_none() {
3677                changed.push(repository_path.clone());
3678                continue;
3679            }
3680            if before_repository.map(|repository| &repository.gitlinks)
3681                != after_repository.map(|repository| &repository.gitlinks)
3682            {
3683                changed.push(repository_path.join("<gitlinks>"));
3684            }
3685            let mut paths = BTreeSet::new();
3686            if let Some(repository) = before_repository {
3687                paths.extend(repository.tracked.keys().cloned());
3688            }
3689            if let Some(repository) = after_repository {
3690                paths.extend(repository.tracked.keys().cloned());
3691            }
3692            for path in paths {
3693                let before = before_repository.and_then(|repository| repository.tracked.get(&path));
3694                let current = after_repository.and_then(|repository| repository.tracked.get(&path));
3695                let worktree_changed =
3696                    before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
3697                let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
3698                    != current.map(|entry| (&entry.index_mode, &entry.index_oid));
3699                if !worktree_changed {
3700                    continue;
3701                }
3702                if !index_changed {
3703                    changed.push(repository_path.join(&path));
3704                    continue;
3705                }
3706                let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
3707                let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
3708                let Some(current_entry) = current else {
3709                    continue;
3710                };
3711                let Some(current_worktree) = current_worktree else {
3712                    continue;
3713                };
3714                let content_changed =
3715                    before_worktree.map(|file| file.content) != Some(current_worktree.content);
3716                let mode_changed = before_worktree.map(|file| file.mode.as_str())
3717                    != Some(current_worktree.mode.as_str());
3718                let repository = cwd.join(&repository_path);
3719                let represented_content = if content_changed {
3720                    filter_was_run = true;
3721                    let result =
3722                        filtered_index_content(&repository, &path, &current_entry.index_oid);
3723                    self.refuse_changed_attributes(cwd, baseline)?;
3724                    match result {
3725                        Ok(expected) => expected == current_worktree.content,
3726                        Err(error) => {
3727                            filter_problem = Some(format!(
3728                                "could not verify transformed content for {:?}: {}",
3729                                repository_path.join(&path),
3730                                error.last_line()
3731                            ));
3732                            false
3733                        }
3734                    }
3735                } else {
3736                    true
3737                };
3738                let represented_mode =
3739                    !mode_changed || current_worktree.mode == current_entry.index_mode;
3740                if !represented_content || !represented_mode {
3741                    changed.push(repository_path.join(&path));
3742                }
3743                if filter_problem.is_some() {
3744                    break 'repositories;
3745                }
3746            }
3747        }
3748        if filter_was_run {
3749            self.refuse_changed_attributes(cwd, baseline)?;
3750            let verified = safe_git_state(cwd).map_err(|e| {
3751                uncertain_worktree_change(
3752                    cwd,
3753                    format!(
3754                        "could not recheck tracked files after verifying transformed content in \
3755                         {}: {}. The worktree was kept for recovery.",
3756                        cwd.display(),
3757                        e.last_line()
3758                    ),
3759                )
3760            })?;
3761            let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3762                uncertain_worktree_change(
3763                    cwd,
3764                    format!(
3765                        "could not recheck untracked files after verifying transformed content \
3766                         in {}: {}. The worktree was kept for recovery.",
3767                        cwd.display(),
3768                        e.last_line()
3769                    ),
3770                )
3771            })?;
3772            if verified != after
3773                || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3774            {
3775                return Err(uncertain_worktree_change(
3776                    cwd,
3777                    "a content filter changed the worktree while SPAR verified the managed \
3778                     commit. The worktree was kept for recovery.",
3779                ));
3780            }
3781            self.refuse_changed_existing_untracked(cwd, baseline)?;
3782        }
3783        if let Some(problem) = filter_problem {
3784            return Err(uncertain_worktree_change(
3785                cwd,
3786                format!("{problem}. The worktree was kept for recovery."),
3787            ));
3788        }
3789        if changed.is_empty() {
3790            return Ok(());
3791        }
3792        let listed = list_paths(&changed);
3793        Err(uncertain_worktree_change(
3794            cwd,
3795            format!(
3796                "the editing call changed tracked working-file bytes, modes, repositories, or \
3797                 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3798                 recovery."
3799            ),
3800        ))
3801    }
3802
3803    pub(crate) fn refuse_changed_attributes(
3804        &self,
3805        cwd: &Path,
3806        baseline: &WorktreeBaseline,
3807    ) -> Result<()> {
3808        let after = attribute_state(cwd).map_err(|e| {
3809            uncertain_worktree_change(
3810                cwd,
3811                format!(
3812                    "could not verify attribute files in {} after editing: {}. The worktree was \
3813                     kept for recovery.",
3814                    cwd.display(),
3815                    e.last_line()
3816                ),
3817            )
3818        })?;
3819        if after == baseline.attributes {
3820            return Ok(());
3821        }
3822        Err(uncertain_worktree_change(
3823            cwd,
3824            format!(
3825                "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
3826                 refused to run a Git operation that could select a new external filter.",
3827                cwd.display()
3828            ),
3829        ))
3830    }
3831
3832    /// Commit a successful editing call from the trusted harness process.
3833    ///
3834    /// Editing sandboxes only need the working tree. They never need writable
3835    /// access to the repository's object database, refs, config, or hooks.
3836    pub(crate) fn commit_pending_changes(
3837        &self,
3838        cwd: &Path,
3839        baseline: &WorktreeBaseline,
3840        preferred_subject: &str,
3841        fallback_subject: &str,
3842    ) -> Result<bool> {
3843        let mut artifacts = IgnoredArtifacts::default();
3844        self.refuse_changed_attributes(cwd, baseline)?;
3845        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3846        refuse_unsafe_index_flags(cwd)?;
3847        if !self.has_uncommitted_changes(cwd)? {
3848            artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3849            artifacts.report(cwd);
3850            return Ok(false);
3851        }
3852        self.stage_managed_changes(cwd, baseline).map_err(|e| {
3853            e.with_message(format!(
3854                "could not stage changes in {}: {}",
3855                cwd.display(),
3856                e.last_line()
3857            ))
3858        })?;
3859        // Git ignore rules decide which files stay outside the commit.
3860        // Whether cleanup can delete them is a separate decision.
3861        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3862        let changed_gitlinks = changed_staged_gitlinks(cwd)?;
3863        if !changed_gitlinks.is_empty() {
3864            let listed = list_paths(&changed_gitlinks);
3865            bail!(
3866                "the editing call added or changed a gitlink at {listed}. It was staged but not \
3867                 committed because the referenced repository objects might exist only inside \
3868                 this worktree. The worktree was kept for recovery."
3869            );
3870        }
3871        let mut subject = self.clean_title(preferred_subject)?;
3872        if subject.trim().is_empty() {
3873            subject = self.clean_title(fallback_subject)?;
3874        }
3875        self.commit_staged_changes(cwd, &subject).map_err(|e| {
3876            e.with_message(format!(
3877                "could not commit changes in {}: {}. The staged files were kept.",
3878                cwd.display(),
3879                e.last_line()
3880            ))
3881        })?;
3882        if has_tracked_or_staged_work(cwd)? {
3883            bail!(
3884                "the commit in {} left additional uncommitted files. They were kept for \
3885                 recovery.",
3886                cwd.display()
3887            );
3888        }
3889        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3890        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3891        artifacts.report(cwd);
3892        Ok(true)
3893    }
3894
3895    fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
3896        let after = ignored_untracked_state(cwd)?;
3897        self.git_at_without_automation(cwd, &["add", "-u"])?;
3898        let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
3899        if paths.is_empty() {
3900            return Ok(());
3901        }
3902        let mut input = Vec::new();
3903        for path in paths {
3904            input.extend(os_str_bytes(path.as_os_str())?);
3905            input.push(0);
3906        }
3907        let argv = git_without_automation_argv(&[
3908            "--literal-pathspecs",
3909            "add",
3910            "--pathspec-from-file=-",
3911            "--pathspec-file-nul",
3912        ]);
3913        proc::run_with_input_bytes(
3914            &argv,
3915            &self.git_opts(Some(cwd), true).stop_descendants(true),
3916            &input,
3917        )?;
3918        Ok(())
3919    }
3920
3921    /// Commit an index prepared by the parent without signing, hooks, or
3922    /// inherited repository automation.
3923    pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
3924        self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
3925            .map(|_| ())
3926    }
3927
3928    /// How many commits `refname` carries that the base does not.
3929    ///
3930    /// Counted from the commits themselves rather than from `commit_subjects`,
3931    /// which drops a commit whose message is empty. The guards in
3932    /// `worktree_add` decide whether to delete a branch on this number, and an
3933    /// empty message must not read as an empty branch.
3934    pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
3935        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3936        self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
3937            .trim()
3938            .parse()
3939            .unwrap_or(0)
3940    }
3941
3942    /// One `hash subject` line per commit `refname` carries that the base does
3943    /// not, oldest first. For showing a person what is on a branch, so the
3944    /// hash keeps a commit with no message from listing as nothing.
3945    pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3946        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3947        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3948            .lines()
3949            .map(str::to_string)
3950            .collect()
3951    }
3952
3953    /// The commits `later` carries that `earlier` does not, oldest first, when
3954    /// `earlier` is genuinely behind it.
3955    ///
3956    /// `None` when it is not an ancestor, which is not the same as nothing
3957    /// having landed. `rewrite_commits_if_needed` rewrites hashes from the first
3958    /// offending commit spar made onward, so a head recorded part way through a
3959    /// round can still be a readable object and no longer be on the branch. `git log` answers that
3960    /// with every commit on the branch, so without the check the one caller
3961    /// would report the whole branch as unread, which is the widest possible
3962    /// wrong answer.
3963    ///
3964    /// No `base_ref` resolution, unlike its neighbours: these are commits rather
3965    /// than branch names, and putting a sha through it logs a fallback line
3966    /// every time.
3967    pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3968        let ancestor = self
3969            .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3970            .is_ok();
3971        if !ancestor {
3972            return None;
3973        }
3974        let range = format!("{earlier}..{later}");
3975        Some(
3976            self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3977                .lines()
3978                .map(str::to_string)
3979                .collect(),
3980        )
3981    }
3982
3983    /// The subjects of the commits `refname` carries that the base does not,
3984    /// oldest first.
3985    pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3986        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3987        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3988            .lines()
3989            .map(str::trim)
3990            .filter(|line| !line.is_empty())
3991            .map(str::to_string)
3992            .collect()
3993    }
3994
3995    /// The paths this checkout changes relative to the base, sorted.
3996    ///
3997    /// A three dot range, matching `diff_stat`: what the branch did, not what
3998    /// the base has done since.
3999    ///
4000    /// `--no-renames` because a rename reported as its destination alone leaves
4001    /// the source out of the list, and a part carrying only the destination
4002    /// would be a copy. As a deletion and an addition it is two paths, which a
4003    /// part can carry together or leave to the leftover report.
4004    ///
4005    /// `-z` because without it git writes paths for display: anything
4006    /// non-ASCII comes back escaped and wrapped in quotes, and that string is
4007    /// not a path. A part built from one carries a pathspec matching no file,
4008    /// so the file never reaches the slice while every list still says the part
4009    /// took it. It also keeps a path with a space at either end intact.
4010    pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
4011        let range = format!("{}...HEAD", self.base_ref(cwd, base));
4012        self.git_try_at(
4013            Some(cwd),
4014            &["diff", "--name-only", "--no-renames", "-z", &range],
4015        )
4016        .split('\0')
4017        .filter(|path| !path.is_empty())
4018        .map(str::to_string)
4019        .collect()
4020    }
4021
4022    /// The lines a line comment can go on: the new side of each hunk of the
4023    /// branch's three dot diff, which is the diff GitHub shows for a pull
4024    /// request.
4025    ///
4026    /// Every flag that shapes the hunks is spelled out as git's own default,
4027    /// which is what GitHub shows, so a user's `diff.noprefix`, `diff.context`,
4028    /// `diff.interHunkContext`, `diff.algorithm` or `diff.submodule` cannot
4029    /// move a line comment onto a line GitHub does not show. `GIT_DIFF_OPTS`
4030    /// is emptied for the same reason: git reads it after the command line,
4031    /// so it would widen every hunk past `--unified=3`. `--no-ext-diff` and
4032    /// `--no-textconv` also matter for more than the shape: the branch being
4033    /// reviewed is somebody else's, and a diff driver or text converter would
4034    /// run a script it changed. A failure is no hunks, which only means every
4035    /// finding stays in the summary.
4036    pub fn diff_hunks(&self, cwd: &Path, base: &str) -> Hunks {
4037        let range = format!("{}...HEAD", self.base_ref(cwd, base));
4038        let argv = git_without_maintenance_argv(&[
4039            "-c",
4040            "core.quotePath=false",
4041            "diff",
4042            "--no-color",
4043            "--no-ext-diff",
4044            "--no-textconv",
4045            "--unified=3",
4046            "--inter-hunk-context=0",
4047            "--diff-algorithm=myers",
4048            "--indent-heuristic",
4049            "--submodule=short",
4050            "--find-renames",
4051            "--src-prefix=a/",
4052            "--dst-prefix=b/",
4053            &range,
4054        ]);
4055        let opts = self.git_opts(Some(cwd), false).env("GIT_DIFF_OPTS", "");
4056        new_side_hunks(&proc::run(&argv, &opts).unwrap_or_default())
4057    }
4058
4059    /// The files one commit range touched, for checking a claim against the
4060    /// diff rather than against the report that made it.
4061    pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
4062        self.git_try_at(
4063            Some(cwd),
4064            &[
4065                "diff",
4066                "--name-only",
4067                "--no-renames",
4068                "-z",
4069                &format!("{from}..{to}"),
4070            ],
4071        )
4072        .split('\0')
4073        .filter(|path| !path.is_empty())
4074        .map(str::to_string)
4075        .collect()
4076    }
4077
4078    /// Where `refname` left the base: the commit its own change is measured
4079    /// from, and the one a slice of that change has to be taken against.
4080    pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
4081        let base_ref = self.base_ref(cwd, base);
4082        let out = self
4083            .git_at(Some(cwd), &["merge-base", &base_ref, refname])
4084            .map_err(|e| {
4085                spar_err!(
4086                    "could not find where {refname} and {base_ref} diverged. {}",
4087                    e.last_line()
4088                )
4089            })?;
4090        let sha = out.trim().to_string();
4091        if sha.is_empty() {
4092            bail!("{refname} and {base_ref} share no history");
4093        }
4094        Ok(sha)
4095    }
4096
4097    pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
4098        let range = format!("{}...HEAD", self.base_ref(cwd, base));
4099        let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
4100        full.trim().to_string()
4101    }
4102
4103    /// Scrub commit messages that slipped past the prompt.
4104    ///
4105    /// `git filter-branch` calls back into this same binary, so there is no
4106    /// interpreter to find and no second copy of the rules to drift.
4107    ///
4108    /// `floor` is the head spar found when it started, and nothing at or below
4109    /// it is rewritten. The style gate is for what spar writes, and a person's
4110    /// commit message is not that: rewriting one changes a published SHA, which
4111    /// diverges the branch under somebody's own checkout and is exactly the
4112    /// history rewrite this design promises never to do. A `None` floor means
4113    /// every commit above the base is spar's own, which is true of a branch
4114    /// spar created in this invocation and of nothing else.
4115    pub fn rewrite_commits_if_needed(
4116        &self,
4117        cwd: &Path,
4118        base: &str,
4119        floor: Option<&str>,
4120    ) -> Result<()> {
4121        let base_ref = self.base_ref(cwd, base);
4122        let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
4123        self.report_commits_left_alone(cwd, &base_ref, floor);
4124
4125        let range = format!("{floor}..HEAD");
4126        let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
4127
4128        let offenders = raw
4129            .split('\x1e')
4130            .filter_map(|entry| entry.split_once('\0'))
4131            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4132            .count();
4133        if offenders == 0 {
4134            return Ok(());
4135        }
4136        logdim!("{offenders} commit message(s) violated style rules, rewriting");
4137
4138        let exe = self_binary()?;
4139        let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
4140
4141        let argv: Vec<String> = [
4142            "git",
4143            "filter-branch",
4144            "-f",
4145            "--msg-filter",
4146            &filter,
4147            &range,
4148        ]
4149        .iter()
4150        .map(|s| s.to_string())
4151        .collect();
4152        let opts = ExecOpts::new()
4153            .cwd(cwd)
4154            .check(false)
4155            .timeout_secs(600)
4156            .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
4157            .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
4158            .env(
4159                "SPAR_BAN_AI_ATTRIBUTION",
4160                bool_env(self.style.ban_ai_attribution),
4161            );
4162        let _ = proc::run(&argv, &opts);
4163
4164        let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
4165        if !style::violations(&after, &self.style).is_empty() {
4166            bail!(
4167                "commit messages still violate style rules after a rewrite in {}.",
4168                cwd.display()
4169            );
4170        }
4171        Ok(())
4172    }
4173
4174    /// Say out loud when a message spar will not touch breaks the style rules.
4175    ///
4176    /// Silence here would read as "there was nothing to scrub", and the point
4177    /// of the floor is that there was something and spar chose to leave it.
4178    fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
4179        if floor == base_ref {
4180            return;
4181        }
4182        let raw = self.git_try_at(
4183            Some(cwd),
4184            &[
4185                "log",
4186                &format!("{base_ref}..{floor}"),
4187                "--format=%H%x00%B%x1e",
4188            ],
4189        );
4190        let left: Vec<&str> = raw
4191            .split('\x1e')
4192            .filter_map(|entry| entry.split_once('\0'))
4193            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4194            .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
4195            .collect();
4196        if left.is_empty() {
4197            return;
4198        }
4199        logdim!(
4200            "{} commit message(s) already on the branch break the style rules and were left \
4201             alone: {}. spar does not rewrite commits it did not write.",
4202            left.len(),
4203            left.join(", ")
4204        );
4205    }
4206
4207    /// Push by explicit refspec from HEAD, leasing the head spar reviewed.
4208    ///
4209    /// A resumed PR is checked out under a local name (`pr-N`) that does not
4210    /// match its remote branch, so pushing by branch name would resolve the
4211    /// wrong local ref or fail outright.
4212    ///
4213    /// `expected` is the remote head spar last read. A bare `--force-with-lease`
4214    /// leases the implicit tracking ref instead, which is shared across every
4215    /// worktree and is updated by any `git fetch`. The editing agent is a real
4216    /// CLI with shell access, and fetching is a normal thing for it to do: one
4217    /// `git fetch` mid round moves the tracking ref to whatever the remote holds
4218    /// and the lease then passes against a head spar never reviewed, which
4219    /// silently drops somebody's commit. Naming the head makes the push refuse
4220    /// exactly that case.
4221    pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
4222        self.push_to(cwd, &PushTarget::Origin, branch, expected)
4223    }
4224
4225    /// `push`, to wherever the branch was first published.
4226    pub fn push_to(
4227        &self,
4228        cwd: &Path,
4229        target: &PushTarget,
4230        branch: &str,
4231        expected: Option<&str>,
4232    ) -> Result<()> {
4233        let expected = expected.map(str::trim).filter(|head| !head.is_empty());
4234        if let Some(head) = expected {
4235            self.refuse_push_that_drops(cwd, head)?;
4236        }
4237        let pushed = self
4238            .git_push(cwd, target, branch, expected)
4239            .map_err(|e| push_error(target, branch, &e));
4240        self.record_write(pushed)
4241    }
4242
4243    /// Publish a branch created in this invocation, from a fork if origin
4244    /// refuses it.
4245    ///
4246    /// Only a new branch can move. A pull request that already exists has its
4247    /// branch where it is, and pushing it anywhere else would not update it. A
4248    /// new one has no pull request yet, so a fork is just where it is opened
4249    /// from, which is how anybody without write access contributes.
4250    ///
4251    /// One write whichever way it goes. The refusal is not counted as a
4252    /// failure when the fork takes the branch: the run did what it set out to,
4253    /// and a failed write makes the exit status say otherwise.
4254    pub fn publish_new_branch(&self, cwd: &Path, branch: &str, base: &str) -> Result<PushTarget> {
4255        let origin = PushTarget::Origin;
4256        let refused = match self.git_push(cwd, &origin, branch, None) {
4257            Ok(()) => return self.record_write(Ok(origin)),
4258            Err(e) if self.fork_when_denied && push_denial(&e.to_string()).is_some() => e,
4259            Err(e) => {
4260                let err = push_error(&origin, branch, &e);
4261                let err = if push_denial(&e.to_string()).is_some() {
4262                    spar_err!(
4263                        "{err} Set fork_when_denied = true under [loop] to publish it from a fork \
4264                         instead."
4265                    )
4266                } else {
4267                    err
4268                };
4269                return self.record_write(Err(err));
4270            }
4271        };
4272        self.record_write(self.publish_on_fork(cwd, branch, base, &refused))
4273    }
4274
4275    fn publish_on_fork(
4276        &self,
4277        cwd: &Path,
4278        branch: &str,
4279        base: &str,
4280        refused: &crate::error::SparError,
4281    ) -> Result<PushTarget> {
4282        let fork = self.fork().map_err(|e| {
4283            spar_err!(
4284                "{} spar could not publish it from a fork instead: {}",
4285                push_error(&PushTarget::Origin, branch, refused),
4286                e.last_line()
4287            )
4288        })?;
4289        let lease = self.fork_branch_lease(&fork, branch, base)?;
4290        let refused = refused.to_string();
4291        log!(
4292            "{} Publishing {branch} from the fork {} instead.",
4293            push_denial(&refused).unwrap_or("origin refused the push."),
4294            fork.full_name
4295        );
4296        let target = PushTarget::Fork(fork);
4297        // Not `push_to`: a lease on a branch a closed pull request left behind
4298        // replaces it, and that branch was never this HEAD's to contain.
4299        self.git_push(cwd, &target, branch, lease.as_deref())
4300            .map_err(|e| push_error(&target, branch, &e))?;
4301        Ok(target)
4302    }
4303
4304    /// What the first push of `branch` to the fork leases against.
4305    ///
4306    /// Nothing when the fork has no such branch. Its tip when a pull request
4307    /// already holds every commit on it beyond `base`, which is what a closed
4308    /// pull request leaves, since GitHub keeps the branch on the fork. Anything
4309    /// else is work only that branch has, and is refused the way an issue
4310    /// branch on origin is.
4311    fn fork_branch_lease(&self, fork: &Fork, branch: &str, base: &str) -> Result<Option<String>> {
4312        let live_ref = format!("refs/heads/{branch}");
4313        let unreadable = |e: crate::error::SparError| {
4314            spar_err!(
4315                "could not check whether the fork {} already has {branch}. {}",
4316                fork.full_name,
4317                e.last_line()
4318            )
4319        };
4320        let listed = self
4321            .git(&["ls-remote", "--heads", &fork.url, &live_ref])
4322            .map_err(unreadable)?;
4323        let Some(tip) = remote_head_oid(&listed, &live_ref)? else {
4324            return Ok(None);
4325        };
4326        let local = format!("refs/spar/fork/{branch}");
4327        self.git(&[
4328            "fetch",
4329            "--no-tags",
4330            &fork.url,
4331            &format!("+{live_ref}:{local}"),
4332        ])
4333        .map_err(unreadable)?;
4334        let ahead = self.commit_count_checked(&self.root, &local, base);
4335        let held = matches!(ahead, Ok(0)) || self.pull_request_holds(branch, &local, base);
4336        self.git_try(&["update-ref", "-d", &local]);
4337        let ahead = ahead?;
4338        if !held {
4339            bail!(
4340                "the fork {} already has {branch} with {ahead} commit(s) that are not on {base}, \
4341                 and no pull request accounts for them. Publishing over it would lose that work.\n\
4342                 Delete it with `git push {} --delete {branch}` if it is no longer needed, then \
4343                 run spar again.",
4344                fork.full_name,
4345                fork.url
4346            );
4347        }
4348        Ok(Some(tip))
4349    }
4350
4351    fn git_push(
4352        &self,
4353        cwd: &Path,
4354        target: &PushTarget,
4355        branch: &str,
4356        expected: Option<&str>,
4357    ) -> Result<()> {
4358        let refspec = format!("HEAD:refs/heads/{branch}");
4359        let lease = match (expected, target) {
4360            (Some(head), _) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
4361            // A fork is pushed to by URL, so there is no tracking ref for a
4362            // bare lease to read. Leasing against nothing makes the first push
4363            // a create that never moves a branch already there.
4364            (None, PushTarget::Fork(_)) => format!("--force-with-lease=refs/heads/{branch}:"),
4365            (None, PushTarget::Origin) => "--force-with-lease".to_string(),
4366        };
4367        self.git_at(Some(cwd), &["push", &lease, target.remote(), &refspec])
4368            .map(|_| ())
4369    }
4370
4371    /// The authenticated user's fork of this repository, made if there is none.
4372    ///
4373    /// GitHub answers a request to fork with the fork that already exists, so
4374    /// asking is safe either way, but it is a write and it is only made when
4375    /// looking found nothing. A new fork is copied in the background, so it is
4376    /// waited for before anything is pushed to it.
4377    fn fork(&self) -> Result<Fork> {
4378        match self.existing_fork() {
4379            Ok(Some(fork)) => return Ok(fork),
4380            Ok(None) => {}
4381            Err(e) => logdim!("{e} Asking GitHub to fork it, which returns any fork there is."),
4382        }
4383        let answer = self
4384            .gh(&["api", "-X", "POST", "repos/{owner}/{repo}/forks"])
4385            .map_err(|e| spar_err!("could not fork this repository. {}", e.last_line()))?;
4386        let fork = fork_from_api(&answer, &self.origin_push_url())?;
4387        self.wait_for_fork(&fork)?;
4388        Ok(self.fork.get_or_init(|| fork).clone())
4389    }
4390
4391    /// The authenticated user's fork of this repository, if there is one,
4392    /// found without making one.
4393    fn existing_fork(&self) -> Result<Option<Fork>> {
4394        if let Some(fork) = self.fork.get() {
4395            return Ok(Some(fork.clone()));
4396        }
4397        let answer = self
4398            .gh(&[
4399                "api",
4400                "graphql",
4401                "-F",
4402                "owner={owner}",
4403                "-F",
4404                "name={repo}",
4405                "-f",
4406                "query=query($owner: String!, $name: String!) { repository(owner: $owner, name: \
4407                 $name) { forks(first: 1, affiliations: [OWNER]) { nodes { nameWithOwner owner { \
4408                 login } sshUrl url } } } }",
4409                "--jq",
4410                ".data.repository.forks.nodes",
4411            ])
4412            .map_err(|e| {
4413                spar_err!(
4414                    "could not look for a fork of this repository. {}",
4415                    e.last_line()
4416                )
4417            })?;
4418        let found = fork_from_listing(&answer, &self.origin_push_url())?;
4419        Ok(found.map(|fork| self.fork.get_or_init(|| fork).clone()))
4420    }
4421
4422    /// Whether GitHub says the authenticated user cannot push here. False
4423    /// when it cannot say, so an unanswered question skips the early check
4424    /// rather than guessing.
4425    fn origin_refuses_push(&self) -> bool {
4426        self.gh_try(&["api", "repos/{owner}/{repo}", "--jq", ".permissions.push"])
4427            .trim()
4428            == "false"
4429    }
4430
4431    fn origin_push_url(&self) -> String {
4432        self.git_try(&["remote", "get-url", "--push", "origin"])
4433    }
4434
4435    /// Where spar can push a pull request's branch: origin for a branch in
4436    /// this repository, the fork for one on the authenticated user's own fork,
4437    /// such as one spar opened after origin refused it, and nowhere for
4438    /// anybody else's.
4439    pub fn pr_push_target(&self, pr: &PrView) -> Option<PushTarget> {
4440        if !pr.is_cross_repository {
4441            return Some(PushTarget::Origin);
4442        }
4443        let viewer = self.viewer_login().ok()?;
4444        own_fork_target(pr, viewer, &self.origin_push_url())
4445    }
4446
4447    fn wait_for_fork(&self, fork: &Fork) -> Result<()> {
4448        const TRIES: u32 = 30;
4449        let mut tries = 1;
4450        loop {
4451            match self.git(&["ls-remote", &fork.url, "HEAD"]) {
4452                Ok(_) => return Ok(()),
4453                Err(e) if tries >= TRIES => bail!(
4454                    "the fork {} could not be reached after {} seconds. {}",
4455                    fork.full_name,
4456                    TRIES * 2,
4457                    e.last_line()
4458                ),
4459                Err(_) => {
4460                    tries += 1;
4461                    std::thread::sleep(std::time::Duration::from_secs(2));
4462                }
4463            }
4464        }
4465    }
4466
4467    /// Refuse a push whose HEAD does not contain the head spar reviewed.
4468    ///
4469    /// The lease answers the remote moving. This answers the local side moving:
4470    /// an agent that reset or rebased the worktree leaves a HEAD that would
4471    /// publish over the reviewed head rather than on top of it, and the lease
4472    /// cannot tell the difference because the remote is exactly where spar left
4473    /// it.
4474    fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
4475        let head = self.head_oid_checked(cwd)?;
4476        if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
4477            return Ok(());
4478        }
4479        bail!(
4480            "refusing to push {head} because it does not contain the reviewed head {expected}. \
4481             The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
4482             the intended commits on top of it before resuming.",
4483            cwd.display()
4484        )
4485    }
4486
4487    /// Create one remote branch for a split without ever moving an existing ref.
4488    ///
4489    /// `worktree_for_split` chooses a name that is free locally and on origin,
4490    /// but another writer can still take it before the push. An empty expected
4491    /// value in the lease makes this an atomic create: it creates an absent ref,
4492    /// accepts an identical ref as a no-op, and never moves an existing ref.
4493    /// The shared `push` method cannot be used because its lease permits
4494    /// updating a ref fetched earlier.
4495    pub fn push_split_branch(
4496        &self,
4497        cwd: &Path,
4498        branch: &str,
4499    ) -> std::result::Result<(), SplitPushError> {
4500        let remote_ref = format!("refs/heads/{branch}");
4501        let lease = format!("--force-with-lease={remote_ref}:");
4502        let refspec = format!("HEAD:{remote_ref}");
4503        let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
4504            Ok(_) => Ok(()),
4505            Err(push_error) => {
4506                let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
4507                let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
4508                reconcile_failed_split_push(branch, push_error, local, remote)
4509            }
4510        };
4511        self.record_write(result)
4512    }
4513
4514    // -- gh ---------------------------------------------------------------
4515
4516    pub fn gh(&self, args: &[&str]) -> Result<String> {
4517        self.gh_at(None, args)
4518    }
4519
4520    pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
4521        let mut argv = vec!["gh".to_string()];
4522        argv.extend(args.iter().map(|s| s.to_string()));
4523        proc::run(
4524            &argv,
4525            &ExecOpts::new()
4526                .cwd(cwd.unwrap_or(&self.root))
4527                .timeout_secs(300),
4528        )
4529    }
4530
4531    /// Run gh with something on its stdin.
4532    ///
4533    /// A tracker body is far too long to pass on argv, and `--body-file -` is
4534    /// how gh takes one. `proc::exec` already wires the pipe, so this is a
4535    /// sibling of `gh_at` rather than anything new.
4536    pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
4537        let mut argv = vec!["gh".to_string()];
4538        argv.extend(args.iter().map(|s| s.to_string()));
4539        proc::run(
4540            &argv,
4541            &ExecOpts::new()
4542                .cwd(&self.root)
4543                .timeout_secs(300)
4544                .stdin(stdin),
4545        )
4546    }
4547
4548    pub fn gh_try(&self, args: &[&str]) -> String {
4549        let mut argv = vec!["gh".to_string()];
4550        argv.extend(args.iter().map(|s| s.to_string()));
4551        proc::run(
4552            &argv,
4553            &ExecOpts::new()
4554                .cwd(&self.root)
4555                .check(false)
4556                .timeout_secs(300),
4557        )
4558        .unwrap_or_default()
4559    }
4560
4561    /// `gh api -i`: the status line and headers with the body, and the exit
4562    /// code ignored on purpose. A `304 Not Modified`, which is the answer a
4563    /// conditional poll wants, makes gh exit 1 while printing exactly what is
4564    /// needed. Only a spawn failure or a timeout is an error here.
4565    pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
4566        let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
4567        for (name, value) in headers {
4568            argv.push("-H".to_string());
4569            argv.push(format!("{name}: {value}"));
4570        }
4571        argv.push(endpoint.to_string());
4572        let out = proc::exec(
4573            &argv,
4574            &ExecOpts::new()
4575                .cwd(&self.root)
4576                .check(false)
4577                .timeout_secs(300),
4578        )?;
4579        parse_api_response(&out.stdout)
4580            .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
4581    }
4582
4583    /// Every open pull request, with what a sweep needs to know about each.
4584    pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
4585        let text = self.gh(&[
4586            "pr",
4587            "list",
4588            "--state",
4589            "open",
4590            "--limit",
4591            "500",
4592            "--json",
4593            PR_SWEEP_FIELDS,
4594        ])?;
4595        serde_json::from_str(&text)
4596            .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
4597    }
4598
4599    /// One pull request, open or not, with what a sweep needs to know.
4600    pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
4601        let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
4602        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4603    }
4604
4605    /// Whether `login` is in a team, named `org/slug` as gh gives a team in
4606    /// `reviewRequests`. GitHub answers 404 for a team the login is not in, and
4607    /// a team the viewer is in is one it can always see. None when GitHub did
4608    /// not answer either way.
4609    pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
4610        // A team asked for a review belongs to the repository's own owner.
4611        let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
4612        if slug.is_empty() || login.is_empty() {
4613            return None;
4614        }
4615        let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
4616        let resp = self.gh_api_raw(&path, &[]).ok()?;
4617        match resp.status {
4618            200 => {
4619                let body: Value = serde_json::from_str(&resp.body).ok()?;
4620                Some(body.get("state")?.as_str()? == "active")
4621            }
4622            404 => Some(false),
4623            _ => None,
4624        }
4625    }
4626
4627    /// Every review request made on a pull request, oldest first. None when
4628    /// GitHub did not answer.
4629    ///
4630    /// A review spar posts as a comment leaves the request it answered open,
4631    /// so `reviewRequests` alone cannot tell that request from one made after
4632    /// the review. When it was made can.
4633    pub fn review_requests_made(&self, number: i64) -> Option<Vec<crate::model::ReviewRequested>> {
4634        let text = self
4635            .gh(&[
4636                "api",
4637                "graphql",
4638                "-F",
4639                "owner={owner}",
4640                "-F",
4641                "repo={repo}",
4642                "-F",
4643                &format!("number={number}"),
4644                "-f",
4645                &format!("query={REQUESTS_QUERY}"),
4646            ])
4647            .ok()?;
4648        parse_review_requests(&text)
4649    }
4650
4651    /// How many commits lie between two SHAs, when GitHub will say.
4652    pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
4653        if from.is_empty() || to.is_empty() {
4654            return None;
4655        }
4656        let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
4657        self.gh_try(&["api", &path, "--jq", ".total_commits"])
4658            .trim()
4659            .parse()
4660            .ok()
4661    }
4662
4663    /// `owner/name`, for the line that says what is being watched.
4664    pub fn repo_name(&self) -> Option<String> {
4665        let name = self.gh_try(&[
4666            "repo",
4667            "view",
4668            "--json",
4669            "nameWithOwner",
4670            "--jq",
4671            ".nameWithOwner",
4672        ]);
4673        let name = name.trim();
4674        (!name.is_empty()).then(|| name.to_string())
4675    }
4676
4677    pub fn listen_state_path(&self) -> PathBuf {
4678        self.root.join(STATE_DIR).join("state").join("listen.json")
4679    }
4680
4681    pub fn listen_lock_path(&self) -> PathBuf {
4682        self.root.join(STATE_DIR).join("state").join("listen.lock")
4683    }
4684
4685    /// The login `gh` is authenticated as.
4686    ///
4687    /// A hard error, never a degradation. Everything spar wrote has to be
4688    /// excluded from what it answers, and custody cannot be read from git
4689    /// authorship, so this is the only thing that tells spar's own comments
4690    /// from somebody else's. Without it the failure is not "answers a bit too
4691    /// much", it is a thread where spar answers itself until somebody notices.
4692    ///
4693    /// Not cached on disk: `gh auth switch` between runs would make a stored
4694    /// answer wrong in exactly the way that produces that thread.
4695    pub fn viewer_login(&self) -> Result<&str> {
4696        if let Some(login) = self.viewer.get() {
4697            return Ok(login);
4698        }
4699        let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
4700        let login = if !rest.trim().is_empty() {
4701            rest.trim().to_string()
4702        } else {
4703            // A token that cannot read /user can still answer for itself in
4704            // GraphQL, which is the case on some Enterprise installs.
4705            self.gh(&[
4706                "api",
4707                "graphql",
4708                "-f",
4709                "query={ viewer { login } }",
4710                "--jq",
4711                ".data.viewer.login",
4712            ])
4713            .map_err(|e| {
4714                spar_err!(
4715                    "could not find out who `gh` is authenticated as, so spar cannot tell its \
4716                     own comments from anybody else's. {}\nRun `gh auth status`.",
4717                    e.last_line()
4718                )
4719            })?
4720            .trim()
4721            .to_string()
4722        };
4723        if login.is_empty() {
4724            bail!("`gh` reported an empty login. Run `gh auth status`.");
4725        }
4726        Ok(self.viewer.get_or_init(|| login))
4727    }
4728
4729    /// One issue as it stands, open or closed.
4730    ///
4731    /// `fetch_issues` reads a queue to work: it drops a closed issue and fails
4732    /// when nothing survives. Both are wrong for reading one issue back, where
4733    /// closed is an answer and the empty case cannot arise.
4734    pub fn read_issue(&self, number: i64) -> Result<Issue> {
4735        let text = self
4736            .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4737            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4738        serde_json::from_str(&text)
4739            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
4740    }
4741
4742    pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
4743        let mut issues = Vec::new();
4744        for number in numbers {
4745            let text = self
4746                .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4747                .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4748            let issue: Issue = serde_json::from_str(&text)
4749                .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4750            if issue.is_closed() {
4751                crate::log!("issue #{number} is closed, skipping");
4752                continue;
4753            }
4754            issues.push(issue);
4755        }
4756        if issues.is_empty() {
4757            bail!("no open issues to work on");
4758        }
4759        Ok(issues)
4760    }
4761
4762    /// Open items, lowest numbered first, from `min_number` upward.
4763    ///
4764    /// The floor exists because a long lived repository accumulates a tail of
4765    /// old issues nobody is going to get to, and taking the lowest numbered
4766    /// open items means walking straight into them.
4767    fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4768        #[derive(Deserialize)]
4769        struct Row {
4770            number: i64,
4771        }
4772        let text = self.gh(&[
4773            kind,
4774            "list",
4775            "--state",
4776            "open",
4777            "--limit",
4778            &FETCH_CEILING.to_string(),
4779            "--json",
4780            "number",
4781        ])?;
4782        let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4783        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
4784        numbers.sort_unstable();
4785
4786        let noun = if kind == "issue" { "issues" } else { "PRs" };
4787        let found = numbers.len();
4788        if min_number > 0 {
4789            numbers.retain(|n| *n >= min_number);
4790            let skipped = found - numbers.len();
4791            if skipped > 0 {
4792                crate::log!("{skipped} open {noun} below #{min_number} skipped");
4793            }
4794        }
4795        if found >= FETCH_CEILING {
4796            crate::log!(
4797                "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
4798                 considered."
4799            );
4800        }
4801        if numbers.len() > limit {
4802            crate::log!(
4803                "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
4804                 explicitly.",
4805                numbers.len()
4806            );
4807            numbers.truncate(limit);
4808        }
4809        Ok(numbers)
4810    }
4811
4812    /// Open issues, lowest numbered first. `gh issue list` excludes PRs.
4813    pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4814        self.open_numbers("issue", limit, min_number)
4815    }
4816
4817    pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4818        self.open_numbers("pr", limit, min_number)
4819    }
4820
4821    pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
4822        self.branch_prs(branch, "open").into_iter().next()
4823    }
4824
4825    /// The open pull request for a branch, preserving a failed lookup as an
4826    /// error when the caller is deciding whether a write already landed.
4827    pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
4828        let text = self.gh(&[
4829            "pr",
4830            "list",
4831            "--head",
4832            branch,
4833            "--base",
4834            base,
4835            "--state",
4836            "open",
4837            "--json",
4838            "number,url,title,baseRefName",
4839        ])?;
4840        pr_for_base(&text, branch, base)
4841    }
4842
4843    /// Every pull request opened from this branch, merged and closed ones
4844    /// included, because a commit is preserved by whichever one carries it and
4845    /// that is rarely the newest.
4846    fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
4847        self.branch_prs(branch, "all")
4848    }
4849
4850    fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
4851        let text = self.gh_try(&[
4852            "pr",
4853            "list",
4854            "--head",
4855            branch,
4856            "--state",
4857            state,
4858            "--json",
4859            "number,url,title",
4860        ]);
4861        serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
4862    }
4863
4864    /// Whether a number names an issue or a pull request.
4865    ///
4866    /// `gh issue view` happily returns a pull request when handed its number,
4867    /// so it cannot be used to tell them apart. The issues API carries both and
4868    /// marks a pull request with a `pull_request` key, which is definitive.
4869    pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
4870        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
4871        let text = self
4872            .gh(&[
4873                "api",
4874                &path,
4875                "--jq",
4876                "if .pull_request then \"pr\" else \"issue\" end",
4877            ])
4878            .map_err(|e| {
4879                spar_err!(
4880                    "no issue or pull request #{number} in this repository. {}",
4881                    e.last_line()
4882                )
4883            })?;
4884        match text.trim() {
4885            "pr" => Ok(ItemKind::Pr),
4886            "issue" => Ok(ItemKind::Issue),
4887            other => Err(spar_err!(
4888                "could not tell whether #{number} is an issue or a pull request (got {other:?})"
4889            )),
4890        }
4891    }
4892
4893    /// An open pull request that would close this issue, whoever opened it.
4894    ///
4895    /// spar's own branch naming is checked first because it is exact and cheap.
4896    /// Falling back to GitHub's own issue linkage is what lets spar pick up a
4897    /// pull request a person started on a branch named anything at all.
4898    ///
4899    /// An error rather than `None` when the question could not be asked. The
4900    /// two answers are not interchangeable: "no pull request exists" is the one
4901    /// that makes spar implement over the top of somebody's work and open a
4902    /// second pull request for the same issue.
4903    pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
4904        let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
4905        if let Some(pr) = mine {
4906            return Ok(Some(pr));
4907        }
4908        let listed = match self.gh(&[
4909            "pr",
4910            "list",
4911            "--state",
4912            "open",
4913            "--limit",
4914            &FETCH_CEILING.to_string(),
4915            "--json",
4916            "number,url,title,baseRefName,closingIssuesReferences",
4917        ]) {
4918            Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
4919                spar_err!("could not read the pull request list while looking for #{issue}")
4920            })?,
4921            Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
4922        };
4923        if listed.len() >= FETCH_CEILING {
4924            logwarn!(
4925                "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a                  pull request past it cannot be seen from here."
4926            );
4927        }
4928        let default = self.default_branch(base);
4929        if !base.eq_ignore_ascii_case(&default) {
4930            // GitHub populates closingIssuesReferences only for pull requests
4931            // targeting the default branch, so on any other base the linkage is
4932            // silence rather than an answer.
4933            logwarn!(
4934                "#{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."
4935            );
4936        }
4937        Ok(pick_linked_pr(&listed, issue))
4938    }
4939
4940    /// spar's own branch, with a failed lookup kept as a failure.
4941    fn pr_for_branch_failing_closed(
4942        &self,
4943        branch: &str,
4944        base: &str,
4945        issue: i64,
4946    ) -> Result<Option<PrRef>> {
4947        match self.try_pr_for_branch(branch, base) {
4948            Ok(found) => Ok(found),
4949            Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
4950        }
4951    }
4952
4953    /// What to do when GitHub would not say whether a pull request exists.
4954    ///
4955    /// An error, because "no pull request exists" is the answer that makes spar
4956    /// implement from scratch and open a second pull request over somebody's
4957    /// work. The one exception is a repository gh cannot reach at all: there is
4958    /// no pull request to collide with when there is no GitHub repository in
4959    /// play, and every later write would fail anyway. That case is a warning and
4960    /// a `None`, so a run against a plain git remote behaves as it always did.
4961    fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
4962        if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
4963            return Err(spar_err!(
4964                "could not check whether a pull request already exists for #{issue}: {}. \
4965                 Refusing to implement over the top of work that may already be open.",
4966                e.last_line()
4967            ));
4968        }
4969        logwarn!(
4970            "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
4971             whether a pull request already exists for it.",
4972            e.last_line()
4973        );
4974        Ok(())
4975    }
4976
4977    pub fn pr_view(&self, number: i64) -> Result<PrView> {
4978        let text = self.gh(&[
4979            "pr",
4980            "view",
4981            &number.to_string(),
4982            "--json",
4983            "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,\
4984             isCrossRepository,headRepositoryOwner,headRepository",
4985        ])?;
4986        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4987    }
4988
4989    fn try_pr_state(&self, number: i64) -> Result<String> {
4990        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
4991        serde_json::from_str::<Value>(text.trim())
4992            .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
4993            .get("state")
4994            .and_then(Value::as_str)
4995            .map(str::to_string)
4996            .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
4997    }
4998
4999    pub fn pr_state(&self, number: i64) -> String {
5000        self.try_pr_state(number).unwrap_or_default()
5001    }
5002
5003    /// The commit currently exposed as a pull request's head.
5004    pub fn pr_head_oid(&self, number: i64) -> Result<String> {
5005        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
5006        let oid = serde_json::from_str::<Value>(&text)
5007            .ok()
5008            .and_then(|value| {
5009                value
5010                    .get("headRefOid")
5011                    .and_then(Value::as_str)
5012                    .map(str::trim)
5013                    .filter(|oid| !oid.is_empty())
5014                    .map(str::to_string)
5015            })
5016            .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
5017        Ok(oid)
5018    }
5019
5020    /// Where a pull request's head stands now: what GitHub's pull request
5021    /// names, and, for a branch in this repository, where `origin` has the
5022    /// branch. The two differ until GitHub catches up with a push.
5023    pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
5024        let pull = self.pr_head_oid(pr.number)?;
5025        let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
5026            None
5027        } else {
5028            let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
5029            let listed = self
5030                .git(&["ls-remote", "--heads", "origin", &remote_ref])
5031                .map_err(|e| {
5032                    spar_err!(
5033                        "could not read where {} is on origin: {}",
5034                        pr.head_ref_name.trim(),
5035                        e.last_line()
5036                    )
5037                })?;
5038            remote_head_oid(&listed, &remote_ref)?
5039        };
5040        Ok(LiveHead { pull, branch })
5041    }
5042
5043    /// Open a pull request from `branch`, published at `target`.
5044    ///
5045    /// The lookup that confirms it landed goes by branch name alone, which
5046    /// `gh pr list --head` matches on a fork's pull request too.
5047    pub fn create_pr(
5048        &self,
5049        cwd: &Path,
5050        branch: &str,
5051        target: &PushTarget,
5052        base: &str,
5053        title: &str,
5054        body: &str,
5055    ) -> Result<PrRef> {
5056        let title = self.record_failed_write(self.clean_title(title))?;
5057        let body = self.record_failed_write(self.clean(body))?;
5058        let head = target.head(branch);
5059        let mut argv = vec![
5060            "pr", "create", "--base", base, "--head", &head, "--title", &title, "--body", &body,
5061        ];
5062        if self.drafts != Drafts::Never {
5063            argv.push("--draft");
5064        }
5065        let created = self.gh_at(Some(cwd), &argv);
5066        let found = self.try_pr_for_branch(branch, base);
5067        self.record_write(reconcile_pr_creation(branch, created, found))
5068    }
5069
5070    pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
5071        let body = signed(&self.record_failed_write(self.clean(body))?);
5072        let comments = self.record_failed_write(self.try_issue_comments(number))?;
5073        if has_exact_comment(&comments, &body) {
5074            return Ok(());
5075        }
5076        let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
5077        let result = match posted {
5078            Ok(_) => Ok(()),
5079            Err(post_error) => {
5080                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5081            }
5082        };
5083        self.record_write(result)
5084    }
5085
5086    /// Whether the viewer wrote this pull request. GitHub refuses to let an
5087    /// author approve or request changes on their own, so a verdict on one can
5088    /// only ever be a comment.
5089    fn pr_is_own(&self, number: i64) -> Result<bool> {
5090        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
5091        let author = serde_json::from_str::<Value>(&text)
5092            .ok()
5093            .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
5094            .unwrap_or_default();
5095        let viewer = self.viewer_login()?.to_string();
5096        Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
5097    }
5098
5099    /// Submit a formal review, or a comment carrying the verdict's words when
5100    /// GitHub will not take the verdict: the viewer's own pull request, or no
5101    /// verdict at all.
5102    ///
5103    /// Mirrors `comment_pr` step for step: the style gate, the signature, a
5104    /// duplicate check against the viewer's own reviews, the write, and a read
5105    /// back on failure so an unverifiable write is reported as one.
5106    ///
5107    /// `commit` is the commit the reviewers read. GitHub attaches a review
5108    /// that names no commit to whatever the head is when it arrives, so a push
5109    /// made while a review ran would otherwise receive an approval nobody
5110    /// gave it. `gh pr review` cannot name one, hence the REST call.
5111    ///
5112    /// `comments` go on their lines in the same review, so they arrive with
5113    /// it or not at all. GitHub refuses the whole review over one line its diff
5114    /// does not show, so when it refused this one and the read back finds
5115    /// nothing landed, the review goes up again without them: the body already
5116    /// says everything they do. Any other failure is reconciled as a review
5117    /// without them would be, never retried: a gateway error can arrive while
5118    /// GitHub is still saving a review that will land.
5119    pub fn submit_review(
5120        &self,
5121        number: i64,
5122        verdict: Option<crate::model::ReviewVerdict>,
5123        body: &str,
5124        commit: &str,
5125        comments: &[InlineComment],
5126    ) -> Result<()> {
5127        let own = match verdict {
5128            Some(_) => self.record_failed_write(self.pr_is_own(number))?,
5129            None => false,
5130        };
5131        let text = match (verdict, own) {
5132            (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
5133            _ => body.to_string(),
5134        };
5135        let body = signed(&self.record_failed_write(self.clean(&text))?);
5136        let mut inline = Vec::with_capacity(comments.len());
5137        for c in comments {
5138            inline.push(InlineComment {
5139                body: signed(&self.record_failed_write(self.clean(&c.body))?),
5140                ..c.clone()
5141            });
5142        }
5143        let viewer = self.record_failed_write(self.viewer_login())?.to_string();
5144        let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
5145        if has_viewer_review(&reviews, &viewer, &body, commit) {
5146            return Ok(());
5147        }
5148        let event = match (verdict, own) {
5149            (Some(v), false) => v.api_event(),
5150            _ => "COMMENT",
5151        };
5152        let result = if inline.is_empty() {
5153            self.post_review(number, &body, &viewer, commit, event)
5154        } else {
5155            let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5156            let payload = review_payload(commit, event, &body, &inline).to_string();
5157            match self.gh_stdin(
5158                &["api", "-X", "POST", &path, "--input", "-", "--silent"],
5159                &payload,
5160            ) {
5161                Ok(_) => Ok(()),
5162                Err(post_error) if !refused(&post_error) => reconcile_review_post(
5163                    number,
5164                    &body,
5165                    &viewer,
5166                    commit,
5167                    post_error,
5168                    self.try_pr_reviews(number),
5169                ),
5170                Err(post_error) => match self.try_pr_reviews(number) {
5171                    Ok(reviews) if !has_viewer_review(&reviews, &viewer, &body, commit) => {
5172                        logwarn!(
5173                            "the review of #{number} with its line comments did not go up ({}), \
5174                             so it is going up with every finding in its body only",
5175                            post_error.last_line()
5176                        );
5177                        self.post_review(number, &body, &viewer, commit, event)
5178                    }
5179                    reviews => {
5180                        reconcile_review_post(number, &body, &viewer, commit, post_error, reviews)
5181                    }
5182                },
5183            }
5184        };
5185        self.record_write(result)
5186    }
5187
5188    /// A review with its body alone, read back if the post reports a failure.
5189    fn post_review(
5190        &self,
5191        number: i64,
5192        body: &str,
5193        viewer: &str,
5194        commit: &str,
5195        event: &str,
5196    ) -> Result<()> {
5197        let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5198        let posted = self.gh(&[
5199            "api",
5200            "-X",
5201            "POST",
5202            &path,
5203            "-f",
5204            &format!("commit_id={commit}"),
5205            "-f",
5206            &format!("event={event}"),
5207            "-f",
5208            &format!("body={body}"),
5209            "--silent",
5210        ]);
5211        match posted {
5212            Ok(_) => Ok(()),
5213            Err(post_error) => reconcile_review_post(
5214                number,
5215                body,
5216                viewer,
5217                commit,
5218                post_error,
5219                self.try_pr_reviews(number),
5220            ),
5221        }
5222    }
5223
5224    pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
5225        let body = signed(&self.record_failed_write(self.clean(body))?);
5226        let comments = self.record_failed_write(self.try_issue_comments(number))?;
5227        if has_exact_comment(&comments, &body) {
5228            return Ok(());
5229        }
5230        let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
5231        let result = match posted {
5232            Ok(_) => Ok(()),
5233            Err(post_error) => {
5234                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5235            }
5236        };
5237        self.record_write(result)
5238    }
5239
5240    /// Comment, then close as not planned.
5241    ///
5242    /// Only ever called when both agents independently declined the issue: one
5243    /// agent's opinion is not enough to close somebody's report.
5244    pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
5245        self.comment_issue(number, body)?;
5246        let n = number.to_string();
5247        let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
5248            Ok(_) => Ok(()),
5249            // Older gh builds do not take --reason.
5250            Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
5251                spar_err!(
5252                    "commented on #{number} but could not close it: {}",
5253                    e.last_line()
5254                )
5255            }),
5256        };
5257        self.record_write(closed)
5258    }
5259
5260    /// Replace an issue body, refusing unless it is still byte for byte what
5261    /// the caller read and validating only the fragment spar inserted.
5262    ///
5263    /// The only place spar rewrites text somebody else wrote, so the check is
5264    /// the whole point: an edit computed from a body that has since moved would
5265    /// silently delete whatever moved it. The caller decides whether another
5266    /// attempt is safe for its workflow.
5267    ///
5268    /// Deliberately not through `clean_issue_body`. The body is mostly a
5269    /// person's own prose, and the scrub would rewrite their punctuation while
5270    /// the length budget could truncate the end of a long report. `inserted` is
5271    /// the only text here spar is answerable for, so it still passes through the
5272    /// style gate. The full body travels over stdin because a tracker can be far
5273    /// too long for one argument.
5274    pub fn edit_issue_body(
5275        &self,
5276        number: i64,
5277        expected: &str,
5278        body: &str,
5279        inserted: &str,
5280    ) -> Result<()> {
5281        let cleaned = self.record_failed_write(self.clean(inserted))?;
5282        if cleaned.trim() != inserted.trim() {
5283            return self.record_failed_write(Err(spar_err!(
5284                "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
5285            )));
5286        }
5287        let current = self.record_failed_write(self.issue_body(number))?;
5288        if current != expected {
5289            return self.record_failed_write(Err(spar_err!(
5290                "the body of #{number} changed since it was read, so it was left alone rather \
5291                 than written over."
5292            )));
5293        }
5294        let edited = self.gh_stdin(
5295            &["issue", "edit", &number.to_string(), "--body-file", "-"],
5296            body,
5297        );
5298        let result = match edited {
5299            Ok(_) => Ok(()),
5300            Err(edit_error) => {
5301                reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
5302            }
5303        };
5304        self.record_write(result)
5305    }
5306
5307    /// One issue's body, exactly as GitHub holds it.
5308    pub fn issue_body(&self, number: i64) -> Result<String> {
5309        #[derive(Deserialize)]
5310        struct Row {
5311            #[serde(default)]
5312            body: Option<String>,
5313        }
5314        let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
5315        let row: Row = serde_json::from_str(text.trim())
5316            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
5317        Ok(row.body.unwrap_or_default())
5318    }
5319
5320    /// Every open issue with its title and body, in one call.
5321    ///
5322    /// For a screen that has to say something about each of twenty items before
5323    /// anything expensive happens. One call rather than one per issue.
5324    pub fn open_issue_rows(&self) -> Vec<Issue> {
5325        let text = self.gh_try(&[
5326            "issue",
5327            "list",
5328            "--state",
5329            "open",
5330            "--limit",
5331            &FETCH_CEILING.to_string(),
5332            "--json",
5333            "number,title,body,labels,state,stateReason,url",
5334        ]);
5335        serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
5336    }
5337
5338    /// Every open pull request with its size, in one call.
5339    pub fn open_pr_rows(&self) -> Vec<PrRow> {
5340        let text = self.gh_try(&[
5341            "pr",
5342            "list",
5343            "--state",
5344            "open",
5345            "--limit",
5346            &FETCH_CEILING.to_string(),
5347            "--json",
5348            "number,title,changedFiles,additions,deletions",
5349        ]);
5350        serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
5351    }
5352
5353    pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
5354        self.create_issue_apart_from(title, body, None)
5355    }
5356
5357    pub fn create_issue_apart_from(
5358        &self,
5359        title: &str,
5360        body: &str,
5361        apart_from: Option<i64>,
5362    ) -> Result<String> {
5363        let title = self.record_failed_write(self.clean_title(title))?;
5364        let body = self.record_failed_write(self.clean_issue_body(body))?;
5365        let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
5366        let result = match created {
5367            Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
5368            created => {
5369                let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
5370                reconcile_issue_creation(&title, created, found)
5371            }
5372        };
5373        self.record_write(result)
5374    }
5375}
5376
5377/// An issue that already covers what spar was about to file.
5378#[derive(Debug, Clone)]
5379pub struct ExistingIssue {
5380    pub number: i64,
5381    pub url: String,
5382    pub title: String,
5383    pub body: String,
5384    pub open: bool,
5385}
5386
5387impl Repo {
5388    pub(crate) fn try_exact_issue_apart_from(
5389        &self,
5390        title: &str,
5391        body: &str,
5392        apart_from: Option<i64>,
5393    ) -> Result<Option<ExistingIssue>> {
5394        #[derive(Deserialize)]
5395        #[serde(rename_all = "camelCase")]
5396        struct Row {
5397            number: i64,
5398            #[serde(default)]
5399            title: String,
5400            #[serde(default)]
5401            url: String,
5402            #[serde(default)]
5403            body: Option<String>,
5404            #[serde(default)]
5405            state: String,
5406        }
5407
5408        let text = self.gh(&[
5409            "issue",
5410            "list",
5411            "--state",
5412            "all",
5413            "--limit",
5414            "100",
5415            "--json",
5416            "number,title,url,body,state,stateReason",
5417        ])?;
5418        let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5419            .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
5420        Ok(rows
5421            .into_iter()
5422            .filter(|row| Some(row.number) != apart_from)
5423            .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
5424            .map(|row| ExistingIssue {
5425                number: row.number,
5426                url: row.url,
5427                title: row.title,
5428                body: row.body.unwrap_or_default(),
5429                open: row.state.eq_ignore_ascii_case("open"),
5430            }))
5431    }
5432
5433    /// An issue that already describes this defect, however it was worded.
5434    ///
5435    /// Exact title matching let duplicates through: two agents, or two runs a
5436    /// week apart, never word one defect identically. A real run filed two
5437    /// duplicates that way, and each had to be closed by hand afterwards.
5438    /// Titles alone are too thin to match on, so this compares titles and
5439    /// bodies together.
5440    pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
5441        self.find_similar_issue_apart_from(title, body, None)
5442    }
5443
5444    /// The same search, with one issue that cannot be its own duplicate.
5445    ///
5446    /// A tracker's body quotes every item in its checklist, so searching for an
5447    /// item's words matches the tracker before it matches anything else. That
5448    /// would link an item to the issue it is written in.
5449    /// The search a tracker runs, which wants closed issues too: a checklist
5450    /// item matching a closed issue is a done item that wants a link.
5451    pub fn find_similar_issue_apart_from(
5452        &self,
5453        title: &str,
5454        body: &str,
5455        apart_from: Option<i64>,
5456    ) -> Option<ExistingIssue> {
5457        self.try_similar_issue(title, body, apart_from, true)
5458            .ok()
5459            .flatten()
5460    }
5461
5462    /// The search a filing runs, over open issues only.
5463    pub fn try_find_similar_issue_apart_from(
5464        &self,
5465        title: &str,
5466        body: &str,
5467        apart_from: Option<i64>,
5468    ) -> Result<Option<ExistingIssue>> {
5469        self.try_similar_issue(title, body, apart_from, false)
5470    }
5471
5472    /// The same search, preserving lookup failure for a caller about to write.
5473    fn try_similar_issue(
5474        &self,
5475        title: &str,
5476        body: &str,
5477        apart_from: Option<i64>,
5478        include_closed: bool,
5479    ) -> Result<Option<ExistingIssue>> {
5480        #[derive(Deserialize)]
5481        #[serde(rename_all = "camelCase")]
5482        struct Row {
5483            number: i64,
5484            #[serde(default)]
5485            title: String,
5486            #[serde(default)]
5487            url: String,
5488            #[serde(default)]
5489            body: String,
5490            #[serde(default)]
5491            state: String,
5492        }
5493        if title.trim().is_empty() {
5494            return Ok(None);
5495        }
5496        // Search on the title's own words: GitHub's index is the cheap way to
5497        // narrow the field before comparing properly.
5498        let query: String = title
5499            .chars()
5500            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5501            .take(120)
5502            .collect();
5503        // Open issues only when something is about to be filed. A fuzzy match
5504        // against a closed one is the case worth keeping rather than dropping:
5505        // a defect that was fixed, closed, and has come back is exactly the
5506        // finding a maintainer wants, and "#N covers it and is closed" wrote it
5507        // nowhere. An exact match is still checked against every state, since
5508        // that is a regression of one specific issue rather than a resemblance
5509        // to one, and a tracker still searches closed issues because a
5510        // checklist item matching one is a done item that wants a link.
5511        let state = if include_closed { "all" } else { "open" };
5512        let text = self.gh(&[
5513            "issue",
5514            "list",
5515            "--state",
5516            state,
5517            "--limit",
5518            "100",
5519            "--search",
5520            query.trim(),
5521            "--json",
5522            "number,title,url,body,state,stateReason",
5523        ])?;
5524        let rows: Vec<Row> = serde_json::from_str(text.trim())
5525            .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
5526
5527        Ok(rows
5528            .into_iter()
5529            .filter(|row| Some(row.number) != apart_from)
5530            .find(|row| {
5531                row.title.trim().eq_ignore_ascii_case(title.trim())
5532                    || textsim::covers(title, body, &row.title, &row.body)
5533            })
5534            .map(|row| ExistingIssue {
5535                number: row.number,
5536                url: row.url,
5537                title: row.title,
5538                open: row.state.eq_ignore_ascii_case("open"),
5539                body: row.body,
5540            }))
5541    }
5542
5543    /// Avoid filing a duplicate when a follow-up already exists.
5544    pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
5545        #[derive(Deserialize)]
5546        struct Row {
5547            title: String,
5548            url: String,
5549        }
5550        let needle = title.trim().to_lowercase();
5551        if needle.is_empty() {
5552            return None;
5553        }
5554        // Quotes and newlines would be read as search syntax rather than text.
5555        let query: String = title
5556            .chars()
5557            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5558            .take(120)
5559            .collect();
5560        let text = self.gh_try(&[
5561            "issue",
5562            "list",
5563            "--state",
5564            "all",
5565            "--limit",
5566            "100",
5567            "--search",
5568            query.trim(),
5569            "--json",
5570            "number,title,url",
5571        ]);
5572        serde_json::from_str::<Vec<Row>>(text.trim())
5573            .ok()?
5574            .into_iter()
5575            .find(|row| row.title.trim().to_lowercase() == needle)
5576            .map(|row| row.url)
5577    }
5578
5579    /// Squash merge, tolerating cleanup failures after a successful merge.
5580    ///
5581    /// Take a pull request out of draft, once the review has converged.
5582    ///
5583    /// Best effort for the remaining workflow. A failure does not discard the
5584    /// review or stop later independent work, but the final write summary
5585    /// reports it and the command returns non-zero.
5586    pub fn mark_ready(&self, number: i64) -> bool {
5587        match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
5588            Ok(_) => true,
5589            Err(e) => {
5590                logdim!(
5591                    "PR #{number} is approved but could not be taken out of draft: {}",
5592                    e.last_line()
5593                );
5594                false
5595            }
5596        }
5597    }
5598
5599    /// The worktree that has `branch` checked out, if one does.
5600    fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
5601        let listed = self
5602            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
5603            .ok()?;
5604        let wanted = format!("branch refs/heads/{branch}");
5605        let mut current: Option<PathBuf> = None;
5606        for line in listed.lines() {
5607            if let Some(path) = line.strip_prefix("worktree ") {
5608                current = Some(PathBuf::from(path));
5609            } else if line == wanted {
5610                return current;
5611            }
5612        }
5613        None
5614    }
5615
5616    /// Finish the local cleanup a merge could not.
5617    ///
5618    /// `gh pr merge --delete-branch` gives up on the local branch whenever any
5619    /// worktree has it checked out, which is the ordinary state rather than an
5620    /// odd one: `spar run` opens the pull request from
5621    /// `.spar-worktrees/issue-N` and leaves the checkout there, and the merge
5622    /// usually happens in a later `spar resume` working somewhere else. The
5623    /// next run's sweep tidied it, so nothing was ever lost, but the run that
5624    /// did the merge reported a failure about a branch nobody will touch again
5625    /// and left a checkout for the next run to explain.
5626    ///
5627    /// Held to the same rules the sweep uses, because they are the rules that
5628    /// decide whether anything would be lost: a branch spar recorded creating,
5629    /// a worktree of spar's own with no recoverable work in it, and a tip some
5630    /// surviving ref preserves. Anything else is left exactly where it is.
5631    fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
5632        let branch = self.pr_view(number).ok()?.head_ref_name;
5633        if branch.is_empty() || !self.known_branches().contains_key(&branch) {
5634            return None;
5635        }
5636        let mut released = None;
5637        if let Some(path) = self.worktree_holding(&branch) {
5638            if !path.starts_with(self.root.join(WORKTREE_DIR)) {
5639                return None;
5640            }
5641            if !matches!(
5642                self.has_recoverable_work(&path, Disposable::BuildOutput),
5643                Ok(false)
5644            ) {
5645                return None;
5646            }
5647            match self.remove_worktree_at(&path) {
5648                Ok(true) => released = Some(path),
5649                _ => return None,
5650            }
5651        }
5652        match self.delete_branch_if_safe(&branch) {
5653            Ok(true) => {
5654                self.forget_branch(&branch);
5655                Some(match released {
5656                    Some(path) => format!(
5657                        " The worktree at {} was released and branch {branch} deleted.",
5658                        path.display()
5659                    ),
5660                    None => format!(" Branch {branch} was deleted."),
5661                })
5662            }
5663            _ => released.map(|path| {
5664                format!(
5665                    " The worktree at {} was released, but branch {branch} was kept.",
5666                    path.display()
5667                )
5668            }),
5669        }
5670    }
5671
5672    /// `gh pr merge --delete-branch` exits non-zero when it cannot delete the
5673    /// local branch, which happens *after* the merge has already landed.
5674    /// Treating that as a failure reports work as lost when it is not.
5675    pub fn merge_pr(&self, number: i64) -> Result<()> {
5676        let n = number.to_string();
5677        let merged = match self.gh(&merge_pr_args(&n, None, true)) {
5678            Ok(_) => Ok(()),
5679            Err(e) => {
5680                if self.pr_state(number) == "MERGED" {
5681                    let finished = self
5682                        .finish_merged_branch_cleanup(number)
5683                        .unwrap_or_default();
5684                    logdim!(
5685                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5686                        e.last_line()
5687                    );
5688                    Ok(())
5689                } else {
5690                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5691                }
5692            }
5693        };
5694        self.record_write(merged)
5695    }
5696
5697    /// Squash merge only if the pull request still exposes the reviewed head.
5698    pub fn merge_pr_at_head(
5699        &self,
5700        number: i64,
5701        expected_head: &str,
5702        delete_branch: bool,
5703    ) -> Result<()> {
5704        let n = number.to_string();
5705        let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
5706            Ok(_) => Ok(()),
5707            Err(e) => {
5708                if self.pr_state(number) == "MERGED" {
5709                    let finished = self
5710                        .finish_merged_branch_cleanup(number)
5711                        .unwrap_or_default();
5712                    logdim!(
5713                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5714                        e.last_line()
5715                    );
5716                    Ok(())
5717                } else {
5718                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5719                }
5720            }
5721        };
5722        self.record_write(merged)
5723    }
5724
5725    // -- follow-ups -------------------------------------------------------
5726
5727    /// The queue of follow-ups recorded locally rather than filed, which
5728    /// `spar followup` works.
5729    pub fn followups_path(&self) -> PathBuf {
5730        self.root.join(STATE_DIR).join("followups.md")
5731    }
5732
5733    /// Where `spar brainstorm` writes a session unless told otherwise.
5734    ///
5735    /// Under the state directory, so it is kept out of the repository's own
5736    /// `git status`; `--out` puts one somewhere a person would commit.
5737    pub fn brainstorms_dir(&self) -> PathBuf {
5738        self.root.join(STATE_DIR).join("brainstorms")
5739    }
5740
5741    /// What `spar followup` already dealt with, kept beside the queue.
5742    ///
5743    /// Two jobs. It is what stops `append_local_followup` re-recording a
5744    /// follow-up whose entry has since left the queue, which would otherwise
5745    /// turn the file into a ring buffer of things already filed. And it keeps
5746    /// the text of an entry a screening pass ruled stale, so a wrong verdict
5747    /// costs a re-read rather than the only copy of a real defect.
5748    pub fn worked_followups_path(&self) -> PathBuf {
5749        self.root.join(STATE_DIR).join("followups.done.md")
5750    }
5751
5752    /// What `spar checkin` has already answered on one pull request or issue.
5753    /// Where what a run spent is kept.
5754    pub fn spend_path(&self) -> PathBuf {
5755        self.root.join(STATE_DIR).join("state").join("spend.json")
5756    }
5757
5758    /// Append this run's calls to the record.
5759    ///
5760    /// Appended rather than replaced, so a resumed pull request adds to the
5761    /// count of what it has cost so far rather than starting it again, and so
5762    /// anybody charting this has a file to chart.
5763    pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
5764        if calls.is_empty() {
5765            return Ok(());
5766        }
5767        let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
5768            .ok()
5769            .and_then(|text| serde_json::from_str(&text).ok())
5770            .unwrap_or_default();
5771        all.extend(calls.iter().cloned());
5772        if let Some(parent) = self.spend_path().parent() {
5773            let _ = std::fs::create_dir_all(parent);
5774        }
5775        write_json_atomic(&self.spend_path(), &all)
5776    }
5777
5778    /// Who wrote the first draft of each issue, by number.
5779    ///
5780    /// Custody alternates within a pull request and the record of it lives in
5781    /// that pull request's state. This is the other question, which nothing
5782    /// answered: over a queue, which model authored what.
5783    pub fn implementors_path(&self) -> PathBuf {
5784        self.root
5785            .join(STATE_DIR)
5786            .join("state")
5787            .join("implementors.json")
5788    }
5789
5790    pub fn read_implementors(&self) -> BTreeMap<i64, String> {
5791        std::fs::read_to_string(self.implementors_path())
5792            .ok()
5793            .and_then(|text| serde_json::from_str(&text).ok())
5794            .unwrap_or_default()
5795    }
5796
5797    /// Best effort: this is a record for a person, not something the loop reads
5798    /// back, so a failure to write it is not a reason to stop.
5799    pub fn remember_implementor(&self, issue: i64, agent: &str) {
5800        let mut known = self.read_implementors();
5801        known.insert(issue, agent.to_string());
5802        if let Some(parent) = self.implementors_path().parent() {
5803            let _ = std::fs::create_dir_all(parent);
5804        }
5805        if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
5806            logdim!("could not record who implemented #{issue}: {e}");
5807        }
5808    }
5809
5810    /// Where the issues triage disagreed about are remembered.
5811    ///
5812    /// One file rather than one per issue: it is read once per run, before any
5813    /// triage call, to decide what not to ask about again.
5814    pub fn contested_path(&self) -> PathBuf {
5815        self.root
5816            .join(STATE_DIR)
5817            .join("state")
5818            .join("contested.json")
5819    }
5820
5821    /// The issues a person has been asked to decide, by number.
5822    pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
5823        std::fs::read_to_string(self.contested_path())
5824            .ok()
5825            .and_then(|text| serde_json::from_str(&text).ok())
5826            .unwrap_or_default()
5827    }
5828
5829    /// Remember an issue triage could not agree on.
5830    ///
5831    /// Best effort: a run that cannot write this asks again next time, which is
5832    /// what it did before this existed.
5833    pub fn remember_contested(&self, items: &[ContestedItem]) {
5834        if items.is_empty() {
5835            return;
5836        }
5837        let mut known = self.read_contested();
5838        for item in items {
5839            known.insert(
5840                item.issue,
5841                Contested {
5842                    title: item.title.clone(),
5843                    positions: item.positions.clone(),
5844                    reasons: item.reasons.clone(),
5845                },
5846            );
5847        }
5848        if let Some(parent) = self.contested_path().parent() {
5849            let _ = std::fs::create_dir_all(parent);
5850        }
5851        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5852            logdim!("could not record the contested issues: {e}");
5853        }
5854    }
5855
5856    /// Ask about them again, because a person has acted on them.
5857    pub fn forget_contested(&self, numbers: &[i64]) {
5858        let mut known = self.read_contested();
5859        let before = known.len();
5860        known.retain(|number, _| !numbers.contains(number));
5861        if known.len() == before {
5862            return;
5863        }
5864        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5865            logdim!("could not update the contested issues: {e}");
5866        }
5867    }
5868
5869    pub fn checkin_state_path(&self, number: i64) -> PathBuf {
5870        self.root
5871            .join(STATE_DIR)
5872            .join("state")
5873            .join(format!("checkin-{number}.json"))
5874    }
5875
5876    /// Where `spar answer` records which mentions on an issue it judged.
5877    pub fn answer_state_path(&self, number: i64) -> PathBuf {
5878        self.root
5879            .join(STATE_DIR)
5880            .join("state")
5881            .join(format!("answer-{number}.json"))
5882    }
5883
5884    /// Where `spar answer` keeps what it left for a person to answer. Notes,
5885    /// never posted.
5886    pub fn answer_notes_path(&self, number: i64) -> PathBuf {
5887        self.root
5888            .join(STATE_DIR)
5889            .join("answers")
5890            .join(format!("issue-{number}.md"))
5891    }
5892
5893    /// Where `spar review` records what it reviewed, and a second look reads it.
5894    pub fn review_record_path(&self, number: i64) -> PathBuf {
5895        self.root
5896            .join(STATE_DIR)
5897            .join("state")
5898            .join(format!("review-{number}.json"))
5899    }
5900
5901    /// None when there is no record, or when it cannot be read. A record spar
5902    /// cannot read is one it reviews afresh over, and the log says so.
5903    pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
5904        let path = self.review_record_path(number);
5905        let text = std::fs::read_to_string(&path).ok()?;
5906        match serde_json::from_str(&text) {
5907            Ok(record) => Some(record),
5908            Err(e) => {
5909                logdim!("could not read {}: {e}", path.display());
5910                None
5911            }
5912        }
5913    }
5914
5915    pub fn write_review_record(
5916        &self,
5917        number: i64,
5918        record: &crate::model::ReviewRecord,
5919    ) -> Result<()> {
5920        write_json_atomic(&self.review_record_path(number), record)
5921    }
5922
5923    /// Append a follow-up to a local note instead of the tracker.
5924    ///
5925    /// Deduplicated on the title, matching the issue path. The body arrives
5926    /// with its provenance already stamped by the caller, so nothing is added
5927    /// here.
5928    ///
5929    /// A write that did not happen is reported as such rather than as a
5930    /// duplicate: the caller settles the point on the strength of this answer,
5931    /// and settling it on a failed write is how a real defect is lost.
5932    ///
5933    /// Both files are checked, because `spar followup` removes an entry from
5934    /// the queue once it has filed it. Checking only the queue would let the
5935    /// next run that rediscovers the same defect append it again, on top of the
5936    /// issue that now exists for it.
5937    pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
5938        let path = self.followups_path();
5939        let heading = format!("## {}", title.trim());
5940        for seen in [&path, &self.worked_followups_path()] {
5941            if let Ok(existing) = std::fs::read_to_string(seen) {
5942                if has_heading(&existing, title) {
5943                    logdim!("follow-up already noted: {title}");
5944                    return Followup::Covered(format!("note: {}", title.trim()));
5945                }
5946            }
5947        }
5948        if let Some(parent) = path.parent() {
5949            let _ = std::fs::create_dir_all(parent);
5950        }
5951        use std::io::Write;
5952        // The caller already stamped the provenance into the body. Adding
5953        // "From #N." here as well printed it twice, in two different wordings.
5954        //
5955        // The marker above the heading is what makes the entry boundary
5956        // unambiguous to the parser, since the body's own sections are written
5957        // at the same heading level as the title.
5958        let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
5959        match std::fs::OpenOptions::new()
5960            .create(true)
5961            .append(true)
5962            .open(&path)
5963        {
5964            Ok(mut file) => match file.write_all(entry.as_bytes()) {
5965                Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
5966                Err(e) => {
5967                    logdim!("could not write {}: {e}", path.display());
5968                    Followup::Failed
5969                }
5970            },
5971            Err(e) => {
5972                logdim!("could not write {}: {e}", path.display());
5973                Followup::Failed
5974            }
5975        }
5976    }
5977
5978    /// Record what `spar followup` did with an entry, and why.
5979    ///
5980    /// Best effort: an archive that could not be written is not a reason to
5981    /// stop, since the entry has already been filed or ruled on.
5982    pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
5983        let path = self.worked_followups_path();
5984        if let Some(parent) = path.parent() {
5985            let _ = std::fs::create_dir_all(parent);
5986        }
5987        use std::io::Write;
5988        let entry = format!(
5989            "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
5990            title.trim(),
5991            body.trim()
5992        );
5993        if let Ok(mut file) = std::fs::OpenOptions::new()
5994            .create(true)
5995            .append(true)
5996            .open(&path)
5997        {
5998            let _ = file.write_all(entry.as_bytes());
5999        }
6000    }
6001
6002    // -- resumable state --------------------------------------------------
6003    //
6004    // Custody cannot be read from GitHub authorship: every agent commits and
6005    // comments as the same git identity, so `author` is always the human who
6006    // ran spar. State is kept on disk by default and can additionally travel in
6007    // a PR comment, which is what lets a run be resumed from another machine.
6008
6009    /// Where a comment spar produced but did not post is kept.
6010    pub fn pending_comment_path(&self, number: i64) -> PathBuf {
6011        self.root
6012            .join(STATE_DIR)
6013            .join("reviews")
6014            .join(format!("pr-{number}.md"))
6015    }
6016
6017    /// Keep a comment spar decided not to post.
6018    ///
6019    /// A dry run that prints and forgets means agreeing with what you read
6020    /// costs a second full review. Saving it makes the whole point of reading
6021    /// it first: look, edit if you like, then post what you already paid for.
6022    pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
6023        let path = self.pending_comment_path(number);
6024        if let Some(parent) = path.parent() {
6025            std::fs::create_dir_all(parent)
6026                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
6027        }
6028        std::fs::write(&path, text)
6029            .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6030        Ok(path)
6031    }
6032
6033    pub fn read_pending_comment(&self, number: i64) -> Option<String> {
6034        std::fs::read_to_string(self.pending_comment_path(number)).ok()
6035    }
6036
6037    pub fn state_path(&self, number: i64) -> PathBuf {
6038        self.root
6039            .join(STATE_DIR)
6040            .join("state")
6041            .join(format!("pr-{number}.json"))
6042    }
6043
6044    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
6045        let path = self.state_path(number);
6046        let text = std::fs::read_to_string(&path).ok()?;
6047        match serde_json::from_str(&text) {
6048            Ok(state) => Some(state),
6049            Err(_) => {
6050                logdim!("could not read {}, starting fresh", path.display());
6051                None
6052            }
6053        }
6054    }
6055
6056    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
6057        if let Some(local) = self.read_local_state(pr.number) {
6058            return Some(local);
6059        }
6060        if self.state_store.writes_pr() {
6061            return self.read_pr_state(pr.number);
6062        }
6063        None
6064    }
6065
6066    pub(crate) fn read_state_for_head(
6067        &self,
6068        pr: &PrView,
6069        actual_head: &str,
6070    ) -> Option<PersistedState> {
6071        let local = self
6072            .state_store
6073            .writes_local()
6074            .then(|| self.read_local_state(pr.number))
6075            .flatten();
6076        let remote = self
6077            .state_store
6078            .writes_pr()
6079            .then(|| self.read_pr_state(pr.number))
6080            .flatten();
6081        let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
6082        if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
6083            self.remember_checkpoint(pr.number, checkpoint);
6084        }
6085        choose_state_for_head(candidates, actual_head)
6086    }
6087
6088    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
6089        self.try_read_pr_state(number).ok().flatten()
6090    }
6091
6092    fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
6093        for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
6094            if let Some(state) = parse_state_comment(&body) {
6095                return Ok(Some(state));
6096            }
6097        }
6098        Ok(None)
6099    }
6100
6101    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6102        let remote_state = if self.state_store.writes_pr() {
6103            self.try_read_pr_state(number)
6104        } else {
6105            Ok(None)
6106        };
6107        self.write_state_after_remote_read(number, state, remote_state)
6108    }
6109
6110    fn write_state_after_remote_read(
6111        &self,
6112        number: i64,
6113        state: &PersistedState,
6114        remote_state: Result<Option<PersistedState>>,
6115    ) -> Result<()> {
6116        let remote_checkpoint = if self.state_store.writes_pr() {
6117            self.record_failed_write(remote_state)?
6118                .map(|saved| saved.checkpoint)
6119                .unwrap_or_default()
6120        } else {
6121            0
6122        };
6123        let local_checkpoint = self
6124            .state_store
6125            .writes_local()
6126            .then(|| self.read_local_state(number))
6127            .flatten()
6128            .map(|saved| saved.checkpoint)
6129            .unwrap_or_default();
6130        let mut stamped = state.clone();
6131        stamped.checkpoint = state
6132            .checkpoint
6133            .max(local_checkpoint)
6134            .max(remote_checkpoint)
6135            .max(self.remembered_checkpoint(number))
6136            .saturating_add(1);
6137        self.remember_checkpoint(number, stamped.checkpoint);
6138        if self.state_store.writes_local() {
6139            write_json_atomic(&self.state_path(number), &stamped)?;
6140        }
6141        if self.state_store.writes_pr() {
6142            self.write_pr_state(number, &stamped)?;
6143        }
6144        Ok(())
6145    }
6146
6147    fn remembered_checkpoint(&self, number: i64) -> u64 {
6148        self.checkpoints
6149            .lock()
6150            .unwrap_or_else(std::sync::PoisonError::into_inner)
6151            .get(&number)
6152            .copied()
6153            .unwrap_or_default()
6154    }
6155
6156    fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
6157        let mut checkpoints = self
6158            .checkpoints
6159            .lock()
6160            .unwrap_or_else(std::sync::PoisonError::into_inner);
6161        let saved = checkpoints.entry(number).or_default();
6162        *saved = (*saved).max(checkpoint);
6163    }
6164
6165    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6166        // Not run through clean(): this is structured data, and scrubbing would
6167        // corrupt refutation text stored in the ledger. It sits inside an
6168        // unclosed HTML comment so GitHub renders it as nothing.
6169        let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
6170        let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
6171        let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
6172        if let Some(id) = comment_id {
6173            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6174            let field = format!("body={body}");
6175            let written = self
6176                .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
6177                .map(|_| ());
6178            return self.record_write(written);
6179        }
6180        let written = self
6181            .gh(&["pr", "comment", &number.to_string(), "--body", &body])
6182            .map(|_| ());
6183        self.record_write(written)
6184    }
6185
6186    /// Top level comments. Works for issues and pull requests alike, because
6187    /// GitHub serves both from the issues endpoint.
6188    ///
6189    /// Nothing when they cannot be read, which suits a reader that is going to
6190    /// go on regardless. A caller deciding whether it has already written here
6191    /// wants `try_issue_comments`, since for that one no comments and no answer
6192    /// are opposite answers.
6193    pub fn issue_comments(&self, number: i64) -> Vec<Value> {
6194        self.try_issue_comments(number).unwrap_or_default()
6195    }
6196
6197    pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
6198        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
6199        try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
6200    }
6201
6202    /// An issue as the REST API returns it, which is the one read that says
6203    /// who filed it in the shape a comment has: `user` with its `type`, and
6204    /// `author_association`, neither of which `gh issue view` gives.
6205    pub fn issue_row(&self, number: i64) -> Result<Value> {
6206        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
6207        let text = self
6208            .gh(&["api", &path])
6209            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
6210        serde_json::from_str(&text)
6211            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
6212    }
6213
6214    /// Open issues in this repository that mention `login`, lowest numbered
6215    /// first.
6216    pub fn open_issues_mentioning(&self, login: &str, limit: usize) -> Result<Vec<i64>> {
6217        #[derive(Deserialize)]
6218        struct Row {
6219            number: i64,
6220        }
6221        // `{owner}/{repo}` is filled in by `gh api` alone.
6222        let name = self
6223            .repo_name()
6224            .ok_or_else(|| spar_err!("could not find out which repository this is"))?;
6225        let text = self.gh(&[
6226            "search",
6227            "issues",
6228            "--repo",
6229            &name,
6230            "--mentions",
6231            login,
6232            "--state",
6233            "open",
6234            "--limit",
6235            &FETCH_CEILING.to_string(),
6236            "--json",
6237            "number",
6238        ])?;
6239        let rows: Vec<Row> = serde_json::from_str(text.trim())
6240            .map_err(|e| spar_err!("unexpected shape for the issues mentioning @{login}: {e}"))?;
6241        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
6242        numbers.sort_unstable();
6243        numbers.truncate(limit);
6244        Ok(numbers)
6245    }
6246
6247    /// spar's own state comments on a pull request, newest last.
6248    ///
6249    /// Three tests, because the answer decides what gets patched over and what
6250    /// gets deleted. A comment matching the marker loosely, which is what
6251    /// somebody asking "why is there a `<!-- spar:state` block on this PR?"
6252    /// writes, is named in the log and left exactly where it is.
6253    fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
6254        let viewer = self.viewer_login()?.to_string();
6255        let mut mine = Vec::new();
6256        let mut theirs = Vec::new();
6257        for comment in self.try_issue_comments(number)? {
6258            let Some(body) = comment.get("body").and_then(Value::as_str) else {
6259                continue;
6260            };
6261            if !body.contains("spar:state") {
6262                continue;
6263            }
6264            let Some(id) = comment.get("id").and_then(Value::as_i64) else {
6265                continue;
6266            };
6267            let author = comment
6268                .get("user")
6269                .and_then(|u| u.get("login"))
6270                .and_then(Value::as_str)
6271                .unwrap_or_default();
6272            if is_state_comment(body, author, &viewer) {
6273                mine.push((id, body.to_string()));
6274            } else {
6275                theirs.push(format!("@{author}"));
6276            }
6277        }
6278        if !theirs.is_empty() {
6279            logdim!(
6280                "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
6281                 own state block is patched or deleted.",
6282                theirs.len(),
6283                theirs.join(", ")
6284            );
6285        }
6286        Ok(mine)
6287    }
6288
6289    fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
6290        Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
6291    }
6292
6293    /// Drop state once the PR is finished and there is nothing to resume.
6294    pub fn clear_state(&self, number: i64) {
6295        let path = self.state_path(number);
6296        let _ = std::fs::remove_file(&path);
6297        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
6298    }
6299
6300    // -- housekeeping -----------------------------------------------------
6301
6302    /// Remove state files whose PR is merged or closed: the custody loop's
6303    /// state, the review record, and the check-in watermark alike. Grouped by
6304    /// number so each finished pull request costs one `gh` read however many
6305    /// files it left.
6306    pub fn prune_state(&self) -> Vec<String> {
6307        let base = self.root.join(STATE_DIR).join("state");
6308        let Ok(entries) = std::fs::read_dir(&base) else {
6309            return Vec::new();
6310        };
6311        let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
6312        for name in entries
6313            .flatten()
6314            .filter_map(|e| e.file_name().to_str().map(str::to_string))
6315        {
6316            if let Some(number) = state_file_number(&name) {
6317                by_number.entry(number).or_default().push(name);
6318            }
6319        }
6320
6321        let mut removed = Vec::new();
6322        for (number, mut names) in by_number {
6323            if !is_finished(&self.pr_state(number)) {
6324                continue;
6325            }
6326            names.sort();
6327            for name in names {
6328                let _ = std::fs::remove_file(base.join(&name));
6329                removed.push(format!("state {name}"));
6330            }
6331        }
6332        removed
6333    }
6334
6335    /// Delete state comments from PRs that are finished.
6336    ///
6337    /// Open PRs are left alone: their state may still be live.
6338    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
6339        #[derive(Deserialize)]
6340        struct Row {
6341            number: i64,
6342        }
6343        let numbers = match numbers {
6344            Some(numbers) => numbers,
6345            None => {
6346                let listed: Result<Vec<i64>> = (|| {
6347                    let text = self.gh(&[
6348                        "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
6349                    ])?;
6350                    let rows = serde_json::from_str::<Vec<Row>>(text.trim())
6351                        .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
6352                    Ok(rows.into_iter().map(|row| row.number).collect())
6353                })();
6354                match self.record_failed_write(listed) {
6355                    Ok(numbers) => numbers,
6356                    Err(e) => {
6357                        logdim!("could not inspect pull requests for state cleanup: {e}");
6358                        return Vec::new();
6359                    }
6360                }
6361            }
6362        };
6363
6364        let mut removed = Vec::new();
6365        for number in numbers {
6366            let state = match self.record_failed_write(self.try_pr_state(number)) {
6367                Ok(state) => state,
6368                Err(e) => {
6369                    logdim!("could not inspect PR #{number} for state cleanup: {e}");
6370                    continue;
6371                }
6372            };
6373            if !is_finished(&state) {
6374                continue;
6375            }
6376            let comments = match self.record_failed_write(self.try_state_comments(number)) {
6377                Ok(comments) => comments,
6378                Err(e) => {
6379                    logdim!("could not inspect state comments on PR #{number}: {e}");
6380                    continue;
6381                }
6382            };
6383            for (id, _) in comments {
6384                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6385                let deleted = self
6386                    .gh(&["api", "-X", "DELETE", &path, "--silent"])
6387                    .map(|_| ());
6388                match self.record_write(deleted) {
6389                    Ok(()) => removed.push(format!("state comment on PR #{number}")),
6390                    Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
6391                }
6392            }
6393        }
6394        removed
6395    }
6396
6397    /// Drop worktrees whose PR is finished, then the branches they left behind.
6398    ///
6399    /// With auto_merge off, which is the default, a run ends at "approved", so
6400    /// nothing would ever clean these up on its own and they accumulate one per
6401    /// run. A stranded worktree also holds its branch checked out, which makes
6402    /// a later `gh pr merge --delete-branch` fail to clean up.
6403    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
6404        let base = self.root.join(WORKTREE_DIR);
6405        let mut removed = Vec::new();
6406        let known = self.known_branches();
6407
6408        if let Ok(entries) = std::fs::read_dir(&base) {
6409            let mut names: Vec<String> = entries
6410                .flatten()
6411                .filter(|e| e.path().is_dir())
6412                .filter_map(|e| e.file_name().to_str().map(str::to_string))
6413                .collect();
6414            names.sort();
6415
6416            for name in names {
6417                // A read checkout is released by the run that made it, so one
6418                // still here was either kept for recovery or left by a run
6419                // that never got to release it.
6420                if let Some(owner) = read_checkout_owner(&name) {
6421                    let path = base.join(&name);
6422                    if force_all {
6423                        self.remove_worktree_at_force(&path);
6424                    } else if process_is_running(owner) {
6425                        continue;
6426                    } else if has_recovery_marker(&path) {
6427                        logdim!("kept {} for recovery", path.display());
6428                        continue;
6429                    } else {
6430                        self.discard_read_checkout(&path);
6431                    }
6432                    if !path.exists() {
6433                        removed.push(name);
6434                    }
6435                    continue;
6436                }
6437                // A review worktree is detached and owns no branch, so it is
6438                // tied to the pull request only by its directory name.
6439                if let Some(rest) = name.strip_prefix("review-") {
6440                    let number: i64 = rest.parse().unwrap_or(-1);
6441                    if !(force_all || is_finished(&self.pr_state(number))) {
6442                        continue;
6443                    }
6444                    let path = base.join(&name);
6445                    if force_all {
6446                        let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
6447                            if !belongs {
6448                                return Ok(false);
6449                            }
6450                            let local_ref = review_ref(number);
6451                            if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
6452                                return Ok(false);
6453                            }
6454                            let head = self.head_oid_checked(&path)?;
6455                            let recorded = self
6456                                .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
6457                                .trim()
6458                                .to_string();
6459                            Ok(head == recorded)
6460                        });
6461                        match owned {
6462                            Ok(true) => {}
6463                            Ok(false) => {
6464                                logdim!(
6465                                    "kept {} because no matching SPAR review reference proves \
6466                                     ownership",
6467                                    path.display()
6468                                );
6469                                continue;
6470                            }
6471                            Err(e) => {
6472                                logdim!(
6473                                    "kept {} because review ownership could not be verified: {}",
6474                                    path.display(),
6475                                    e.last_line()
6476                                );
6477                                continue;
6478                            }
6479                        }
6480                    } else {
6481                        if let Err(e) = self.refuse_review_worktree_changes(number) {
6482                            logdim!(
6483                                "kept {} because its review state could not be verified as \
6484                                 disposable: {}",
6485                                path.display(),
6486                                e.last_line()
6487                            );
6488                            continue;
6489                        }
6490                    }
6491                    if force_all {
6492                        if self.remove_worktree_at_force(&path) {
6493                            self.git_try(&["update-ref", "-d", &review_ref(number)]);
6494                        }
6495                    } else {
6496                        self.release_review_worktree(number);
6497                    }
6498                    if !path.exists() {
6499                        removed.push(name);
6500                    }
6501                    continue;
6502                }
6503                let branch = format!("{}{name}", self.branch_prefix);
6504                if !(force_all || self.worktree_is_done(&branch)) {
6505                    continue;
6506                }
6507                if !known.contains_key(&branch) {
6508                    logdim!("kept {branch} because it has no branch record");
6509                    continue;
6510                }
6511                let path = base.join(&name);
6512                if !force_all {
6513                    match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6514                        Ok(true) => {
6515                            logdim!(
6516                                "kept {} because it contains uncommitted changes or ignored files",
6517                                path.display()
6518                            );
6519                            continue;
6520                        }
6521                        Err(e) => {
6522                            logdim!(
6523                                "kept {} because its Git state could not be checked: {}",
6524                                path.display(),
6525                                e.last_line()
6526                            );
6527                            continue;
6528                        }
6529                        Ok(false) => {}
6530                    }
6531                    match self.branch_deletion_is_safe(&branch) {
6532                        Ok(true) => {}
6533                        Ok(false) => {
6534                            logdim!(
6535                                "kept {branch} because no surviving ref preserves its tip or \
6536                                 reflog-only commits"
6537                            );
6538                            continue;
6539                        }
6540                        Err(e) => {
6541                            logdim!(
6542                                "kept {branch} because preservation could not be verified: {}",
6543                                e.last_line()
6544                            );
6545                            continue;
6546                        }
6547                    }
6548                }
6549                let removed_worktree = if force_all {
6550                    self.remove_worktree_at_force(&path)
6551                } else {
6552                    match self.remove_worktree_at(&path) {
6553                        Ok(removed) => removed,
6554                        Err(error) => {
6555                            logdim!(
6556                                "kept {branch} and {} because removal did not reach a confirmed \
6557                                 quiet point: {}",
6558                                path.display(),
6559                                error.last_line()
6560                            );
6561                            false
6562                        }
6563                    }
6564                };
6565                if !removed_worktree {
6566                    continue;
6567                }
6568                if force_all {
6569                    self.git_try(&["branch", "-D", &branch]);
6570                    self.forget_branch(&branch);
6571                } else {
6572                    match self.delete_branch_if_safe(&branch) {
6573                        Ok(true) => self.forget_branch(&branch),
6574                        Ok(false) => logdim!(
6575                            "kept {branch} because its tip or reflog changed before deletion"
6576                        ),
6577                        Err(error) => logdim!(
6578                            "kept {branch} because deletion safety could not be rechecked: {}",
6579                            error.last_line()
6580                        ),
6581                    }
6582                }
6583                removed.push(name);
6584            }
6585        }
6586        removed.extend(self.prune_branches(force_all));
6587        removed
6588    }
6589
6590    /// Delete leftover branches spar created whose worktree is already gone.
6591    ///
6592    /// Deletion is driven by the ledger of branches spar actually created, not
6593    /// by a name pattern. Names default to `issue-N`, which is exactly what a
6594    /// person would call a branch themselves, so a name alone can never
6595    /// establish ownership. This is the data loss guard.
6596    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
6597        let known = self.known_branches();
6598        let branches: Vec<String> = known.keys().cloned().collect();
6599        if branches.is_empty() {
6600            return Vec::new();
6601        }
6602
6603        let checked_out: Vec<String> = self
6604            .git_try(&["worktree", "list", "--porcelain"])
6605            .lines()
6606            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
6607            .collect();
6608
6609        // %(refname:short) is ambiguous when a tag shares the branch name (it
6610        // yields "heads/..."), so take the full ref and strip it here.
6611        let existing: Vec<String> = self
6612            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
6613            .lines()
6614            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
6615            .collect();
6616
6617        let mut removed = Vec::new();
6618        for branch in branches {
6619            if !existing.contains(&branch) {
6620                self.forget_branch(&branch); // already gone, drop the record
6621                continue;
6622            }
6623            if checked_out.contains(&branch) {
6624                continue;
6625            }
6626            if !(force_all || self.worktree_is_done(&branch)) {
6627                continue;
6628            }
6629            if !force_all {
6630                let Some(_record) = known.get(&branch) else {
6631                    continue;
6632                };
6633                match self.branch_deletion_is_safe(&branch) {
6634                    Ok(true) => {}
6635                    Ok(false) => {
6636                        logdim!(
6637                            "kept {branch} because no surviving ref preserves its tip or \
6638                             reflog-only commits"
6639                        );
6640                        continue;
6641                    }
6642                    Err(e) => {
6643                        logdim!(
6644                            "kept {branch} because preservation could not be verified: {}",
6645                            e.last_line()
6646                        );
6647                        continue;
6648                    }
6649                }
6650            }
6651            let deleted = if force_all {
6652                self.git(&["branch", "-D", &branch]).map(|_| true)
6653            } else {
6654                self.delete_branch_if_safe(&branch)
6655            };
6656            match deleted {
6657                Ok(true) => {
6658                    self.forget_branch(&branch);
6659                    removed.push(format!("branch {branch}"));
6660                }
6661                Ok(false) => {
6662                    logdim!("kept {branch} because its tip or reflog changed before deletion");
6663                }
6664                Err(e) => {
6665                    // A branch that silently survives pruning looks like a spar
6666                    // bug, so the name and git's own reason have to be said.
6667                    logdim!("could not delete {branch}: {}", e.last_line());
6668                }
6669            }
6670        }
6671        removed
6672    }
6673
6674    /// True when the PR behind this branch is merged or closed.
6675    fn worktree_is_done(&self, branch: &str) -> bool {
6676        #[derive(Deserialize)]
6677        struct Row {
6678            state: String,
6679        }
6680        let entry = branch
6681            .strip_prefix(self.branch_prefix.as_str())
6682            .unwrap_or(branch);
6683        if let Some(rest) = entry.strip_prefix("pr-") {
6684            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
6685        }
6686        // A split part is the same shape as an issue branch: one branch, whose
6687        // pull requests say whether it is finished. Without it here, a part
6688        // branch is one nothing but `clean --all` would ever remove.
6689        if entry.starts_with("issue-") || entry.starts_with("split-") {
6690            let text = self.gh_try(&[
6691                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
6692            ]);
6693            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
6694            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
6695        }
6696        false
6697    }
6698}
6699
6700// ---------------------------------------------------------------------------
6701// Free helpers
6702// ---------------------------------------------------------------------------
6703
6704/// Take a read-only probe again before treating its failure as evidence.
6705///
6706/// Every probe is a Git invocation under a thirty second cap plus a walk over a
6707/// tree an agent may still be building in. A probe that fails proves nothing
6708/// about the worktree, and there is a real difference between "the tree moved"
6709/// and "the question could not be asked just then", which the caller turns into
6710/// the same run-ending error.
6711pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
6712    match probe() {
6713        Ok(value) => Ok(value),
6714        Err(first) => {
6715            logdim!("retrying a worktree probe after: {}", first.last_line());
6716            probe()
6717        }
6718    }
6719}
6720
6721/// Read attribute files without asking Git to inspect working-tree content.
6722///
6723/// A newly written attribute can select a clean or smudge filter. It must be
6724/// detected before a post-call status, diff, or add command has a chance to run
6725/// that filter in the parent process.
6726pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
6727    let root = std::fs::canonicalize(cwd)
6728        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6729    let mut files = BTreeMap::new();
6730    let mut visited = BTreeSet::new();
6731    collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
6732    Ok(AttributeState { files })
6733}
6734
6735fn collect_attribute_files(
6736    root: &Path,
6737    repository: &Path,
6738    prefix: &Path,
6739    visited: &mut BTreeSet<PathBuf>,
6740    files: &mut BTreeMap<PathBuf, [u8; 32]>,
6741) -> Result<()> {
6742    let canonical = std::fs::canonicalize(repository)
6743        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6744    if !visited.insert(canonical) {
6745        bail!("submodule recursion revisited {}", repository.display());
6746    }
6747    let entries = index_entries(repository)?;
6748    let mut paths: BTreeSet<PathBuf> = entries
6749        .iter()
6750        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6751        .map(|entry| entry.path.clone())
6752        .collect();
6753    // Ignored on purpose. A `.gitattributes` under build output can only select
6754    // filters for paths that are themselves ignored, and no ignored path is
6755    // ever staged or compared as tracked content, so it cannot reach a managed
6756    // commit. Capturing it meant `cargo package` or `npm ci` writing one into
6757    // its own output ended a read-only call outright.
6758    paths.extend(untracked_attribute_paths(repository)?);
6759    for path in paths {
6760        let from_root = prefix.join(&path);
6761        let state = attribute_file_fingerprint(&root.join(&from_root))?;
6762        files.insert(from_root, state);
6763    }
6764    for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
6765        let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
6766            continue;
6767        };
6768        collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
6769    }
6770    Ok(())
6771}
6772
6773/// Leave a visible, untracked reason ordinary cleanup can detect on a later
6774/// run even when Git status normalizes the original working-file change away.
6775pub(crate) fn uncertain_worktree_change(
6776    cwd: &Path,
6777    message: impl Into<String>,
6778) -> crate::error::SparError {
6779    let message = message.into();
6780    let marker = write_recovery_marker(cwd, &message);
6781    let note = match marker {
6782        Ok(path) => format!(" Recovery marker: {}.", path.display()),
6783        Err(e) => format!(
6784            " A recovery marker could not be written: {}.",
6785            e.last_line()
6786        ),
6787    };
6788    crate::error::SparError::uncertain_write(format!("{message}{note}"))
6789}
6790
6791fn has_recovery_marker(cwd: &Path) -> bool {
6792    let Ok(entries) = std::fs::read_dir(cwd) else {
6793        return false;
6794    };
6795    entries.flatten().any(|entry| {
6796        entry
6797            .file_name()
6798            .to_str()
6799            .is_some_and(|name| name.starts_with(RECOVERY_MARKER_PREFIX))
6800    })
6801}
6802
6803/// The process a read checkout's directory name says made it.
6804fn read_checkout_owner(name: &str) -> Option<u32> {
6805    let rest = name.strip_prefix(READ_CHECKOUT_PREFIX)?;
6806    let (pid, serial) = rest.split_once('-')?;
6807    serial.parse::<usize>().ok()?;
6808    pid.parse().ok()
6809}
6810
6811/// Whether a process may still be running.
6812///
6813/// Anything short of proof that it is gone says yes. A wrong yes leaves a
6814/// checkout for `spar clean --all`; a wrong no deletes one under a running
6815/// call.
6816#[cfg(unix)]
6817fn process_is_running(pid: u32) -> bool {
6818    let Ok(pid) = libc::pid_t::try_from(pid) else {
6819        return true;
6820    };
6821    if pid <= 0 {
6822        return true;
6823    }
6824    if unsafe { libc::kill(pid, 0) } == 0 {
6825        return true;
6826    }
6827    std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
6828}
6829
6830#[cfg(not(unix))]
6831fn process_is_running(_pid: u32) -> bool {
6832    true
6833}
6834
6835fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
6836    use std::sync::atomic::{AtomicU32, Ordering};
6837    static NEXT: AtomicU32 = AtomicU32::new(0);
6838    for _ in 0..1000 {
6839        let serial = NEXT.fetch_add(1, Ordering::Relaxed);
6840        let path = cwd.join(format!(
6841            "{RECOVERY_MARKER_PREFIX}{}-{serial}",
6842            std::process::id()
6843        ));
6844        let mut options = OpenOptions::new();
6845        options.write(true).create_new(true);
6846        #[cfg(unix)]
6847        {
6848            use std::os::unix::fs::OpenOptionsExt;
6849            options.mode(0o600);
6850        }
6851        match options.open(&path) {
6852            Ok(mut file) => {
6853                file.write_all(detail.as_bytes())
6854                    .and_then(|_| file.write_all(b"\n"))
6855                    .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6856                return Ok(path);
6857            }
6858            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
6859            Err(e) => {
6860                return Err(spar_err!(
6861                    "could not create a recovery marker in {}: {e}",
6862                    cwd.display()
6863                ))
6864            }
6865        }
6866    }
6867    bail!(
6868        "could not choose a free recovery marker name in {}",
6869        cwd.display()
6870    )
6871}
6872
6873/// Build a Git command that cannot launch automatic repository maintenance.
6874///
6875/// A fetch may otherwise prune missing linked worktree registrations. SPAR
6876/// must only remove registrations it has proven it owns.
6877fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
6878    let mut argv = vec![
6879        "git".to_string(),
6880        "-c".to_string(),
6881        "maintenance.auto=false".to_string(),
6882        "-c".to_string(),
6883        "gc.auto=0".to_string(),
6884    ];
6885    argv.extend(args.iter().map(|arg| (*arg).to_string()));
6886    argv
6887}
6888
6889fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
6890    let mut argv = git_without_maintenance_argv(&[]);
6891    argv.extend([
6892        "-c".to_string(),
6893        "core.fsmonitor=".to_string(),
6894        "-c".to_string(),
6895        "commit.gpgsign=false".to_string(),
6896        "-c".to_string(),
6897        "core.hooksPath=/dev/null".to_string(),
6898    ]);
6899    argv.extend(args.iter().map(|arg| (*arg).to_string()));
6900    argv
6901}
6902
6903/// Snapshot every untracked file, including ignored files, without changing
6904/// path bytes.
6905///
6906/// Without an exclude option, Git lists both ordinary and ignored untracked
6907/// entries. Metadata fingerprints make overwriting an existing path observable
6908/// without hashing a potentially multi-gigabyte build tree on every call.
6909pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
6910    let root = std::fs::canonicalize(cwd)
6911        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6912    let mut files = BTreeMap::new();
6913    let mut ignored = BTreeSet::new();
6914    let mut visited = BTreeSet::new();
6915    collect_untracked_files(
6916        &root,
6917        &root,
6918        Path::new(""),
6919        &mut visited,
6920        &mut files,
6921        &mut ignored,
6922    )?;
6923    Ok(IgnoredState { files, ignored })
6924}
6925
6926fn collect_untracked_files(
6927    root: &Path,
6928    repository: &Path,
6929    prefix: &Path,
6930    visited: &mut BTreeSet<PathBuf>,
6931    files: &mut BTreeMap<PathBuf, UntrackedFile>,
6932    ignored: &mut BTreeSet<PathBuf>,
6933) -> Result<()> {
6934    let canonical = std::fs::canonicalize(repository)
6935        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6936    if !visited.insert(canonical.clone()) {
6937        bail!("submodule recursion revisited {}", canonical.display());
6938    }
6939    // Classify ordinary paths in the listing itself. Listing every path here
6940    // and marking ignored paths in a second query misclassifies build output
6941    // deleted between queries as ordinary untracked work.
6942    let listed = run_git_bytes(
6943        repository,
6944        &["ls-files", "--others", "--exclude-standard", "-z"],
6945    )?;
6946    if !listed.is_empty() && !listed.ends_with(&[0]) {
6947        bail!(
6948            "git returned an unterminated untracked-file list for {}",
6949            repository.display()
6950        );
6951    }
6952
6953    for raw in listed
6954        .split(|byte| *byte == 0)
6955        .filter(|raw| !raw.is_empty())
6956    {
6957        let (relative, nested) = untracked_record(raw, "untracked")?;
6958        let from_root = prefix.join(&relative);
6959        if is_spar_own_path(&from_root) {
6960            continue;
6961        }
6962        let absolute = root.join(&from_root);
6963        let fingerprint = if nested {
6964            nested_repository_fingerprint(&absolute)?
6965        } else {
6966            ignored_file_fingerprint(&absolute)?
6967        };
6968        if files.insert(from_root.clone(), fingerprint).is_some() {
6969            bail!(
6970                "git returned the untracked path more than once: {:?}",
6971                from_root
6972            );
6973        }
6974    }
6975
6976    let ignored_listed = run_git_bytes(
6977        repository,
6978        &[
6979            "ls-files",
6980            "--others",
6981            "--ignored",
6982            "--exclude-standard",
6983            "-z",
6984        ],
6985    )?;
6986    if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
6987        bail!(
6988            "git returned an unterminated ignored-file list for {}",
6989            repository.display()
6990        );
6991    }
6992    // Two `ls-files` runs cannot be made to agree about a tree that is being
6993    // built in. A path named only by the second one was created between them,
6994    // which is a fact about the moment, not a corruption: record it and let the
6995    // comparison decide, rather than ending a run that had nothing wrong with
6996    // it.
6997    for raw in ignored_listed
6998        .split(|byte| *byte == 0)
6999        .filter(|raw| !raw.is_empty())
7000    {
7001        let (relative, nested) = untracked_record(raw, "ignored")?;
7002        let from_root = prefix.join(relative);
7003        if is_spar_own_path(&from_root) {
7004            continue;
7005        }
7006        if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
7007            let absolute = root.join(&from_root);
7008            let fingerprint = if nested {
7009                nested_repository_fingerprint(&absolute)?
7010            } else {
7011                ignored_file_fingerprint(&absolute)?
7012            };
7013            slot.insert(fingerprint);
7014        }
7015        if !ignored.insert(from_root.clone()) {
7016            bail!(
7017                "git returned the ignored path more than once: {:?}",
7018                from_root
7019            );
7020        }
7021    }
7022
7023    for link in gitlinks(repository)? {
7024        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7025            continue;
7026        };
7027        collect_untracked_files(
7028            root,
7029            &submodule,
7030            &prefix.join(&link.path),
7031            visited,
7032            files,
7033            ignored,
7034        )?;
7035    }
7036    Ok(())
7037}
7038
7039fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
7040    let argv = git_without_automation_argv(args);
7041    proc::run_bytes(
7042        &argv,
7043        &ExecOpts::new()
7044            .cwd(cwd)
7045            .timeout_secs(30)
7046            .stop_descendants(true),
7047    )
7048}
7049
7050fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
7051    let argv = git_without_automation_argv(args);
7052    proc::run(
7053        &argv,
7054        &ExecOpts::new()
7055            .cwd(cwd)
7056            .timeout_secs(30)
7057            .stop_descendants(true),
7058    )
7059}
7060
7061fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
7062    let path = path.to_str().ok_or_else(|| {
7063        spar_err!(
7064            "cannot verify filtered content for a non-UTF-8 path in {}",
7065            cwd.display()
7066        )
7067    })?;
7068    let path_arg = format!("--path={path}");
7069    let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
7070    Ok(Sha256::digest(bytes).into())
7071}
7072
7073fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
7074    let relative = path_from_git_bytes(raw)?;
7075    if relative.is_absolute()
7076        || relative.components().any(|component| {
7077            matches!(
7078                component,
7079                std::path::Component::ParentDir
7080                    | std::path::Component::RootDir
7081                    | std::path::Component::Prefix(_)
7082            )
7083        })
7084    {
7085        bail!("git returned an unsafe {kind} path: {:?}", relative);
7086    }
7087    Ok(relative)
7088}
7089
7090/// Split one `ls-files --others` record into its path and whether Git reported
7091/// a nested repository rather than a single file.
7092///
7093/// Git never lists the contents of a repository inside the working tree, so a
7094/// checkout parked there, such as another of SPAR's own worktrees, arrives as
7095/// one record for the directory itself ending in a separator. Git writes that
7096/// separator on every platform. Trimming it keeps the recorded path equal to
7097/// the same path seen any other way.
7098fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
7099    let nested = raw.last() == Some(&b'/');
7100    let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
7101    if trimmed.is_empty() {
7102        bail!("git returned an empty {kind} path");
7103    }
7104    Ok((safe_git_path(trimmed, kind)?, nested))
7105}
7106
7107fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
7108    let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
7109    if !listed.is_empty() && !listed.ends_with(&[0]) {
7110        bail!(
7111            "git returned an unterminated index listing for {}",
7112            cwd.display()
7113        );
7114    }
7115    let mut entries = Vec::new();
7116    for record in listed
7117        .split(|byte| *byte == 0)
7118        .filter(|record| !record.is_empty())
7119    {
7120        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7121            bail!(
7122                "git returned a malformed index record for {}",
7123                cwd.display()
7124            );
7125        };
7126        let header = &record[..tab];
7127        let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
7128        if fields.len() != 3 {
7129            bail!(
7130                "git returned a malformed index header for {}",
7131                cwd.display()
7132            );
7133        }
7134        if fields[2] != b"0" {
7135            continue;
7136        }
7137        let mode = std::str::from_utf8(fields[0])
7138            .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
7139            .to_string();
7140        let oid = std::str::from_utf8(fields[1])
7141            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7142            .to_string();
7143        entries.push(IndexEntry {
7144            path: safe_git_path(&record[tab + 1..], "index")?,
7145            mode,
7146            oid,
7147        });
7148    }
7149    Ok(entries)
7150}
7151
7152/// Share Git's complete ignore policy between call checkpoints and handoffs.
7153fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
7154    let listed = run_git_bytes(
7155        cwd,
7156        &[
7157            "ls-files",
7158            "--others",
7159            "--exclude-standard",
7160            "-z",
7161            "--",
7162            ".gitattributes",
7163            ":(glob)**/.gitattributes",
7164        ],
7165    )?;
7166    if !listed.is_empty() && !listed.ends_with(&[0]) {
7167        bail!(
7168            "git returned an unterminated attribute-file listing for {}",
7169            cwd.display()
7170        );
7171    }
7172    listed
7173        .split(|byte| *byte == 0)
7174        .filter(|record| !record.is_empty())
7175        .map(|raw| safe_git_path(raw, "attribute"))
7176        .collect()
7177}
7178
7179fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
7180    if !untracked_attribute_paths(cwd)?.is_empty() {
7181        return Ok(true);
7182    }
7183
7184    let index = index_entries(cwd)?
7185        .into_iter()
7186        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7187        .map(|entry| (entry.path, (entry.mode, entry.oid)))
7188        .collect::<BTreeMap<_, _>>();
7189    let head = tree_entries(cwd, "HEAD")?
7190        .into_iter()
7191        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
7192        .map(|entry| (entry.path, (entry.mode, entry.oid)))
7193        .collect::<BTreeMap<_, _>>();
7194    if index != head {
7195        return Ok(true);
7196    }
7197
7198    let effective = check_attributes(cwd, index.keys().cloned())?;
7199    let config = CheckoutConfig::read(cwd)?;
7200    for (path, (_mode, oid)) in index {
7201        let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
7202            return Ok(true);
7203        };
7204        let attributes = effective
7205            .get(&path)
7206            .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
7207        if allows_expected_crlf(&config, attributes)? {
7208            if worktree.mode == "120000" {
7209                return Ok(true);
7210            }
7211            let (normalized, every_lf_was_crlf) =
7212                normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
7213            if !every_lf_was_crlf || normalized != oid {
7214                return Ok(true);
7215            }
7216        } else if worktree.raw_oid != oid {
7217            return Ok(true);
7218        }
7219    }
7220    Ok(false)
7221}
7222
7223fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
7224    Ok(index_entries(cwd)?
7225        .into_iter()
7226        .filter(|entry| entry.mode == "160000")
7227        .map(|entry| Gitlink {
7228            path: entry.path,
7229            oid: entry.oid,
7230        })
7231        .collect())
7232}
7233
7234fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
7235    let mut tracked = BTreeMap::new();
7236    for entry in index_entries(cwd)? {
7237        if entry.mode == "160000" {
7238            continue;
7239        }
7240        let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
7241        tracked.insert(
7242            entry.path,
7243            TrackedEntry {
7244                index_mode: entry.mode,
7245                index_oid: entry.oid,
7246                worktree,
7247            },
7248        );
7249    }
7250    Ok(tracked)
7251}
7252
7253fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
7254    let metadata = match std::fs::symlink_metadata(path) {
7255        Ok(metadata) => metadata,
7256        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7257        Err(e) => {
7258            return Err(spar_err!(
7259                "could not inspect tracked file {}: {e}",
7260                path.display()
7261            ))
7262        }
7263    };
7264    let mut fingerprint = Sha256::new();
7265    if metadata.file_type().is_symlink() {
7266        let target = std::fs::read_link(path)
7267            .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
7268        let bytes = os_str_bytes(target.as_os_str())?;
7269        fingerprint.update(b"symlink\0");
7270        fingerprint.update(&bytes);
7271        let content = Sha256::digest(&bytes).into();
7272        return Ok(Some(WorktreeFile {
7273            mode: "120000".to_string(),
7274            #[cfg(unix)]
7275            permissions: 0,
7276            raw_oid: git_blob_oid(oid_len, &bytes)?,
7277            fingerprint: fingerprint.finalize().into(),
7278            content,
7279        }));
7280    }
7281    if !metadata.is_file() {
7282        bail!("tracked path {} is not a file or symlink", path.display());
7283    }
7284
7285    let mut options = OpenOptions::new();
7286    options.read(true);
7287    #[cfg(unix)]
7288    {
7289        use std::os::unix::fs::OpenOptionsExt;
7290        options.custom_flags(libc::O_NOFOLLOW);
7291    }
7292    let mut file = options
7293        .open(path)
7294        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7295    let before = file
7296        .metadata()
7297        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7298    let mode = tracked_file_mode(&before);
7299    #[cfg(unix)]
7300    let permissions = {
7301        use std::os::unix::fs::MetadataExt;
7302        before.mode() & 0o7777
7303    };
7304    fingerprint.update(b"file\0");
7305    fingerprint.update(mode.as_bytes());
7306    #[cfg(unix)]
7307    fingerprint.update(permissions.to_le_bytes());
7308    fingerprint.update(before.len().to_le_bytes());
7309    let mut content = Sha256::new();
7310    let header = format!("blob {}\0", before.len());
7311    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7312    let mut buf = [0u8; 64 * 1024];
7313    loop {
7314        let read = file
7315            .read(&mut buf)
7316            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7317        if read == 0 {
7318            break;
7319        }
7320        fingerprint.update(&buf[..read]);
7321        content.update(&buf[..read]);
7322        object.update(&buf[..read]);
7323    }
7324    let after = file
7325        .metadata()
7326        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7327    if before.len() != after.len()
7328        || before.modified().ok() != after.modified().ok()
7329        || before.permissions() != after.permissions()
7330    {
7331        bail!(
7332            "tracked file {} changed while it was being inspected",
7333            path.display()
7334        );
7335    }
7336    let current = std::fs::symlink_metadata(path)
7337        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7338    if !same_file(&after, &current) {
7339        bail!(
7340            "tracked file {} was replaced while it was being inspected",
7341            path.display()
7342        );
7343    }
7344    Ok(Some(WorktreeFile {
7345        mode,
7346        #[cfg(unix)]
7347        permissions,
7348        raw_oid: object.finish(),
7349        fingerprint: fingerprint.finalize().into(),
7350        content: content.finalize().into(),
7351    }))
7352}
7353
7354fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
7355    let metadata = std::fs::symlink_metadata(path)
7356        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7357    let mut digest = Sha256::new();
7358    if metadata.file_type().is_symlink() {
7359        digest.update(b"symlink\0");
7360        let target = std::fs::read_link(path)
7361            .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
7362        digest.update(os_str_bytes(target.as_os_str())?);
7363        return Ok(digest.finalize().into());
7364    }
7365    if !metadata.is_file() {
7366        bail!("attribute path {} is not a file or symlink", path.display());
7367    }
7368    let mut options = OpenOptions::new();
7369    options.read(true);
7370    #[cfg(unix)]
7371    {
7372        use std::os::unix::fs::OpenOptionsExt;
7373        options.custom_flags(libc::O_NOFOLLOW);
7374    }
7375    let mut file = options
7376        .open(path)
7377        .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7378    let before = file
7379        .metadata()
7380        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7381    digest.update(b"file\0");
7382    let mut buf = [0u8; 64 * 1024];
7383    loop {
7384        let read = file
7385            .read(&mut buf)
7386            .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7387        if read == 0 {
7388            break;
7389        }
7390        digest.update(&buf[..read]);
7391    }
7392    let after = file
7393        .metadata()
7394        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7395    let current = std::fs::symlink_metadata(path)
7396        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7397    if before.len() != after.len()
7398        || before.modified().ok() != after.modified().ok()
7399        || !same_file(&after, &current)
7400    {
7401        bail!(
7402            "attribute file {} changed while it was being inspected",
7403            path.display()
7404        );
7405    }
7406    Ok(digest.finalize().into())
7407}
7408
7409enum ObjectHasher {
7410    Sha1(Sha1),
7411    Sha256(Sha256),
7412}
7413
7414impl ObjectHasher {
7415    fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
7416        let mut hasher = match oid_len {
7417            40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
7418            64 => Self::Sha256(Sha256::new()),
7419            _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
7420        };
7421        hasher.update(header);
7422        Ok(hasher)
7423    }
7424
7425    fn update(&mut self, bytes: &[u8]) {
7426        match self {
7427            Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
7428            Self::Sha256(hasher) => hasher.update(bytes),
7429        }
7430    }
7431
7432    fn finish(self) -> String {
7433        let bytes = match self {
7434            Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
7435            Self::Sha256(hasher) => hasher.finalize().to_vec(),
7436        };
7437        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
7438    }
7439}
7440
7441fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
7442    let header = format!("blob {}\0", bytes.len());
7443    let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
7444    hasher.update(bytes);
7445    Ok(hasher.finish())
7446}
7447
7448fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
7449    let mut first = open_regular_file(path)?;
7450    let first_before = first
7451        .metadata()
7452        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7453    let mut raw_len = 0u64;
7454    let mut crlf_pairs = 0u64;
7455    let mut previous_was_cr = false;
7456    let mut every_lf_was_crlf = true;
7457    let mut buf = [0u8; 64 * 1024];
7458    loop {
7459        let read = first
7460            .read(&mut buf)
7461            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7462        if read == 0 {
7463            break;
7464        }
7465        raw_len = raw_len
7466            .checked_add(read as u64)
7467            .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
7468        for byte in &buf[..read] {
7469            if *byte == b'\n' {
7470                if previous_was_cr {
7471                    crlf_pairs += 1;
7472                } else {
7473                    every_lf_was_crlf = false;
7474                }
7475            }
7476            previous_was_cr = *byte == b'\r';
7477        }
7478    }
7479    let first_after = first
7480        .metadata()
7481        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7482    let current = std::fs::symlink_metadata(path)
7483        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7484    if raw_len != first_before.len()
7485        || !stable_file_metadata(&first_before, &first_after)
7486        || !stable_file_metadata(&first_after, &current)
7487    {
7488        bail!(
7489            "tracked file {} changed while line endings were inspected",
7490            path.display()
7491        );
7492    }
7493
7494    let normalized_len = raw_len
7495        .checked_sub(crlf_pairs)
7496        .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
7497    let header = format!("blob {normalized_len}\0");
7498    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7499    let mut second = open_regular_file(path)?;
7500    let second_before = second
7501        .metadata()
7502        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7503    if !stable_file_metadata(&first_after, &second_before) {
7504        bail!(
7505            "tracked file {} changed between line-ending checks",
7506            path.display()
7507        );
7508    }
7509    let mut pending_cr = false;
7510    loop {
7511        let read = second
7512            .read(&mut buf)
7513            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7514        if read == 0 {
7515            break;
7516        }
7517        for byte in &buf[..read] {
7518            if pending_cr {
7519                if *byte == b'\n' {
7520                    object.update(b"\n");
7521                    pending_cr = false;
7522                    continue;
7523                }
7524                object.update(b"\r");
7525                pending_cr = false;
7526            }
7527            if *byte == b'\r' {
7528                pending_cr = true;
7529            } else {
7530                object.update(std::slice::from_ref(byte));
7531            }
7532        }
7533    }
7534    if pending_cr {
7535        object.update(b"\r");
7536    }
7537    let second_after = second
7538        .metadata()
7539        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7540    let current = std::fs::symlink_metadata(path)
7541        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7542    if !stable_file_metadata(&second_before, &second_after)
7543        || !stable_file_metadata(&second_after, &current)
7544    {
7545        bail!(
7546            "tracked file {} changed while line endings were hashed",
7547            path.display()
7548        );
7549    }
7550    Ok((object.finish(), every_lf_was_crlf))
7551}
7552
7553fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7554    let mut options = OpenOptions::new();
7555    options.read(true);
7556    #[cfg(unix)]
7557    {
7558        use std::os::unix::fs::OpenOptionsExt;
7559        options.custom_flags(libc::O_NOFOLLOW);
7560    }
7561    let file = options
7562        .open(path)
7563        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7564    let metadata = file
7565        .metadata()
7566        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7567    if !metadata.is_file() {
7568        bail!("tracked path {} is not a regular file", path.display());
7569    }
7570    Ok(file)
7571}
7572
7573fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7574    if !same_file(left, right)
7575        || left.len() != right.len()
7576        || left.modified().ok() != right.modified().ok()
7577        || left.permissions() != right.permissions()
7578    {
7579        return false;
7580    }
7581    #[cfg(unix)]
7582    {
7583        use std::os::unix::fs::MetadataExt;
7584        left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
7585    }
7586    #[cfg(not(unix))]
7587    {
7588        left.created().ok() == right.created().ok()
7589    }
7590}
7591
7592/// How many paths one `check-attr` call is asked about.
7593///
7594/// `check-attr` answers with three NUL fields per attribute per path, so the
7595/// output is several times the input. A whole monorepo in one call is megabytes
7596/// in both directions, and the 30 second timeout below is a timeout on the
7597/// batch. Batching keeps each call small enough to be honest about.
7598const ATTRIBUTE_BATCH: usize = 1000;
7599
7600fn check_attributes(
7601    cwd: &Path,
7602    paths: impl IntoIterator<Item = PathBuf>,
7603) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7604    let paths = paths.into_iter().collect::<BTreeSet<_>>();
7605    if paths.is_empty() {
7606        return Ok(BTreeMap::new());
7607    }
7608    let ordered = paths.into_iter().collect::<Vec<_>>();
7609    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7610    for batch in ordered.chunks(ATTRIBUTE_BATCH) {
7611        values.extend(check_attribute_batch(cwd, batch)?);
7612    }
7613    Ok(values)
7614}
7615
7616fn check_attribute_batch(
7617    cwd: &Path,
7618    batch: &[PathBuf],
7619) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7620    const NAMES: [&str; 6] = [
7621        "filter",
7622        "working-tree-encoding",
7623        "ident",
7624        "text",
7625        "eol",
7626        "crlf",
7627    ];
7628    let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
7629    let mut input = String::new();
7630    for path in &paths {
7631        let path = path.to_str().ok_or_else(|| {
7632            spar_err!(
7633                "cannot inspect attributes for a non-UTF-8 path in {}",
7634                cwd.display()
7635            )
7636        })?;
7637        input.push_str(path);
7638        input.push('\0');
7639    }
7640    let argv = git_without_automation_argv(&[
7641        "check-attr",
7642        "-z",
7643        "--cached",
7644        "--stdin",
7645        "filter",
7646        "working-tree-encoding",
7647        "ident",
7648        "text",
7649        "eol",
7650        "crlf",
7651    ]);
7652    let output = proc::run_bytes(
7653        &argv,
7654        &ExecOpts::new()
7655            .cwd(cwd)
7656            .timeout_secs(30)
7657            .stdin(input)
7658            .stop_descendants(true),
7659    )?;
7660    if !output.is_empty() && !output.ends_with(&[0]) {
7661        bail!(
7662            "git returned an unterminated attribute result for {}",
7663            cwd.display()
7664        );
7665    }
7666    let fields = output
7667        .split(|byte| *byte == 0)
7668        .filter(|field| !field.is_empty())
7669        .collect::<Vec<_>>();
7670    if fields.len() != paths.len() * NAMES.len() * 3 {
7671        bail!(
7672            "git returned an unexpected attribute result for {}",
7673            cwd.display()
7674        );
7675    }
7676    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7677    for record in fields.chunks_exact(3) {
7678        let path = safe_git_path(record[0], "attribute")?;
7679        if !paths.contains(&path) {
7680            bail!(
7681                "git returned attributes for the wrong path in {}",
7682                cwd.display()
7683            );
7684        }
7685        let name = std::str::from_utf8(record[1])
7686            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
7687        let value = std::str::from_utf8(record[2])
7688            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
7689        values
7690            .entry(path)
7691            .or_default()
7692            .insert(name.to_string(), value.to_string());
7693    }
7694    if paths.iter().any(|path| {
7695        values
7696            .get(path)
7697            .is_none_or(|attributes| attributes.len() != NAMES.len())
7698    }) {
7699        bail!(
7700            "git omitted an attribute result for a tracked path in {}",
7701            cwd.display()
7702        );
7703    }
7704    Ok(values)
7705}
7706
7707fn attribute_is_active(value: Option<&String>) -> bool {
7708    !matches!(
7709        value.map(String::as_str),
7710        None | Some("unspecified") | Some("unset")
7711    )
7712}
7713
7714fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
7715    attribute_is_active(values.get("filter"))
7716        || attribute_is_active(values.get("working-tree-encoding"))
7717}
7718
7719/// The checkout settings a per-file line-ending decision depends on.
7720///
7721/// These are constant for a worktree, but the checks that read them run once
7722/// per tracked file, and each read was its own `git config` process. A
7723/// thousand-file repository with no `.gitattributes` takes every one of those
7724/// branches, which is half a minute of process spawning per scan, and the sweep
7725/// before a run scans every finished worktree more than once. Read them here,
7726/// once, and hand them down.
7727struct CheckoutConfig {
7728    autocrlf: Option<String>,
7729    eol: Option<String>,
7730    symlinks: Option<bool>,
7731}
7732
7733impl CheckoutConfig {
7734    fn read(cwd: &Path) -> Result<Self> {
7735        Ok(Self {
7736            autocrlf: git_config_value(cwd, "core.autocrlf")?,
7737            eol: git_config_value(cwd, "core.eol")?,
7738            symlinks: git_config_bool(cwd, "core.symlinks")?,
7739        })
7740    }
7741
7742    fn autocrlf_is_true(&self) -> bool {
7743        self.autocrlf.as_ref().is_some_and(|value| {
7744            matches!(
7745                value.to_ascii_lowercase().as_str(),
7746                "true" | "yes" | "on" | "1"
7747            )
7748        })
7749    }
7750
7751    fn eol_is(&self, wanted: &str) -> bool {
7752        self.eol
7753            .as_ref()
7754            .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
7755    }
7756}
7757
7758fn path_has_ambiguous_transform(
7759    config: &CheckoutConfig,
7760    values: &BTreeMap<String, String>,
7761) -> Result<bool> {
7762    if path_has_external_transform(values)
7763        || attribute_is_active(values.get("ident"))
7764        || attribute_is_active(values.get("crlf"))
7765    {
7766        return Ok(true);
7767    }
7768    let text = values.get("text").map(String::as_str);
7769    let eol = values.get("eol").map(String::as_str);
7770    if text == Some("auto") {
7771        return Ok(true);
7772    }
7773    if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
7774        || !matches!(
7775            eol,
7776            Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
7777        )
7778    {
7779        return Ok(true);
7780    }
7781    if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
7782        return Ok(config.autocrlf_is_true());
7783    }
7784    Ok(false)
7785}
7786
7787fn allows_expected_crlf(
7788    config: &CheckoutConfig,
7789    values: &BTreeMap<String, String>,
7790) -> Result<bool> {
7791    if path_has_external_transform(values)
7792        || attribute_is_active(values.get("ident"))
7793        || attribute_is_active(values.get("crlf"))
7794    {
7795        return Ok(false);
7796    }
7797    let text = values.get("text").map(String::as_str);
7798    let eol = values.get("eol").map(String::as_str);
7799    if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
7800        return Ok(false);
7801    }
7802    if eol == Some("crlf") {
7803        return Ok(true);
7804    }
7805    if text != Some("set") {
7806        return Ok(false);
7807    }
7808    if let Some(autocrlf) = config.autocrlf.as_deref() {
7809        match autocrlf.to_ascii_lowercase().as_str() {
7810            "true" | "yes" | "on" | "1" => return Ok(true),
7811            "input" => return Ok(false),
7812            _ => {}
7813        }
7814    }
7815    if config.eol_is("crlf") {
7816        return Ok(true);
7817    }
7818    #[cfg(windows)]
7819    if config.eol.is_none() || config.eol_is("native") {
7820        return Ok(true);
7821    }
7822    Ok(false)
7823}
7824
7825fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
7826    let argv = git_without_automation_argv(&["config", "--get", key]);
7827    let output = proc::exec(
7828        &argv,
7829        &ExecOpts::new()
7830            .cwd(cwd)
7831            .timeout_secs(30)
7832            .check(false)
7833            .stop_descendants(true),
7834    )?;
7835    match output.code {
7836        0 => Ok(Some(output.stdout.trim().to_string())),
7837        1 => Ok(None),
7838        _ => bail!(
7839            "could not read Git configuration in {}: {}",
7840            cwd.display(),
7841            output.stderr.trim()
7842        ),
7843    }
7844}
7845
7846fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
7847    let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
7848    let output = proc::exec(
7849        &argv,
7850        &ExecOpts::new()
7851            .cwd(cwd)
7852            .timeout_secs(30)
7853            .check(false)
7854            .stop_descendants(true),
7855    )?;
7856    match output.code {
7857        0 if output.stdout.trim() == "true" => Ok(Some(true)),
7858        0 if output.stdout.trim() == "false" => Ok(Some(false)),
7859        0 => bail!(
7860            "git returned an invalid boolean for {key} in {}",
7861            cwd.display()
7862        ),
7863        1 => Ok(None),
7864        _ => bail!(
7865            "could not read Git configuration in {}: {}",
7866            cwd.display(),
7867            output.stderr.trim()
7868        ),
7869    }
7870}
7871
7872#[cfg(unix)]
7873fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
7874    use std::os::unix::fs::PermissionsExt;
7875    if metadata.permissions().mode() & 0o111 == 0 {
7876        "100644".to_string()
7877    } else {
7878        "100755".to_string()
7879    }
7880}
7881
7882#[cfg(not(unix))]
7883fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
7884    "100644".to_string()
7885}
7886
7887fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
7888    let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
7889    if !listed.is_empty() && !listed.ends_with(&[0]) {
7890        bail!(
7891            "git returned an unterminated tree listing for {}",
7892            cwd.display()
7893        );
7894    }
7895    let mut entries = Vec::new();
7896    for record in listed
7897        .split(|byte| *byte == 0)
7898        .filter(|record| !record.is_empty())
7899    {
7900        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7901            bail!("git returned a malformed tree record for {}", cwd.display());
7902        };
7903        let fields = record[..tab]
7904            .split(|byte| *byte == b' ')
7905            .collect::<Vec<_>>();
7906        if fields.len() != 3 {
7907            bail!("git returned a malformed tree header for {}", cwd.display());
7908        }
7909        let mode = std::str::from_utf8(fields[0])
7910            .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
7911            .to_string();
7912        let oid = std::str::from_utf8(fields[2])
7913            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7914            .to_string();
7915        entries.push(IndexEntry {
7916            path: safe_git_path(&record[tab + 1..], "tree")?,
7917            mode,
7918            oid,
7919        });
7920    }
7921    Ok(entries)
7922}
7923
7924fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
7925    Ok(tree_entries(cwd, "HEAD")?
7926        .into_iter()
7927        .filter(|entry| entry.mode == "160000")
7928        .map(|entry| (entry.path, entry.oid))
7929        .collect())
7930}
7931
7932fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
7933    let head = head_gitlinks(cwd)?;
7934    let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
7935        .into_iter()
7936        .map(|link| (link.path, link.oid))
7937        .collect();
7938    let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
7939    paths.extend(index.keys().cloned());
7940    Ok(paths
7941        .into_iter()
7942        .filter(|path| head.get(path) != index.get(path))
7943        .collect())
7944}
7945
7946fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
7947    let path = parent.join(relative);
7948    let metadata = match std::fs::symlink_metadata(&path) {
7949        Ok(metadata) => metadata,
7950        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7951        Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7952    };
7953    if !metadata.is_dir() {
7954        bail!("the gitlink at {} is not a directory", path.display());
7955    }
7956    let canonical = std::fs::canonicalize(&path)
7957        .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
7958    if canonical != path {
7959        bail!(
7960            "the gitlink at {} resolves through a symlink",
7961            path.display()
7962        );
7963    }
7964    if !path.join(".git").exists() {
7965        let empty = std::fs::read_dir(&path)
7966            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
7967            .next()
7968            .is_none();
7969        if empty {
7970            return Ok(None);
7971        }
7972        bail!(
7973            "the uninitialized gitlink at {} contains local files",
7974            path.display()
7975        );
7976    }
7977    let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
7978    if inside.trim() != "true" {
7979        bail!("the gitlink at {} is not a worktree", path.display());
7980    }
7981    let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
7982    let top = std::fs::canonicalize(top.trim()).map_err(|e| {
7983        spar_err!(
7984            "could not resolve the gitlink top level at {}: {e}",
7985            path.display()
7986        )
7987    })?;
7988    if top != canonical {
7989        bail!(
7990            "the gitlink at {} belongs to a different worktree",
7991            path.display()
7992        );
7993    }
7994    Ok(Some(canonical))
7995}
7996
7997/// A Git entry the outer repository cannot account for.
7998///
7999/// A checkout Git itself reports as a nested repository is expected. Its
8000/// directory is recorded by identity in the untracked snapshot and keeps the
8001/// worktree from being released, so a reviewer that clones a reproduction into
8002/// ignored build output neither loses that clone nor fails the run over it.
8003/// The scan does not descend into one, because what is under it belongs to
8004/// that repository.
8005///
8006/// A Git entry Git does not report is the one worth stopping for. Running
8007/// `git init` inside a directory that already holds tracked files leaves
8008/// nothing in any listing while making every file written under it invisible
8009/// to the outer worktree, so SPAR can no longer prove what is there.
8010fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
8011    let root = std::fs::canonicalize(cwd)
8012        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8013    let mut allowed = BTreeSet::from([root.join(".git")]);
8014    let mut checkouts = BTreeSet::new();
8015    let mut repositories = vec![root.clone()];
8016    let mut visited = BTreeSet::new();
8017    while let Some(repository) = repositories.pop() {
8018        let canonical = std::fs::canonicalize(&repository)
8019            .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8020        if !visited.insert(canonical.clone()) {
8021            bail!("submodule recursion revisited {}", canonical.display());
8022        }
8023        for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
8024            let (path, nested) = untracked_record(&raw, "untracked")?;
8025            if nested {
8026                checkouts.insert(canonical.join(path));
8027            }
8028        }
8029        for link in gitlinks(&canonical)? {
8030            let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
8031                continue;
8032            };
8033            allowed.insert(submodule.join(".git"));
8034            repositories.push(submodule);
8035        }
8036    }
8037
8038    // A build deletes its own directories while this walks them. A directory
8039    // that is gone holds no Git entry, so skipping it answers the question
8040    // being asked; erroring instead ended runs whose worktrees were fine.
8041    let scan_root = root.clone();
8042    let mut directories = vec![root];
8043    while let Some(directory) = directories.pop() {
8044        let entries = match std::fs::read_dir(&directory) {
8045            Ok(entries) => entries,
8046            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8047            Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8048        };
8049        for entry in entries {
8050            let entry = match entry {
8051                Ok(entry) => entry,
8052                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8053                Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
8054            };
8055            let path = entry.path();
8056            if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
8057                continue;
8058            }
8059            if entry.file_name() == OsStr::new(".git") {
8060                if !allowed.contains(&path) {
8061                    return Ok(Some(path));
8062                }
8063                continue;
8064            }
8065            let kind = match entry.file_type() {
8066                Ok(kind) => kind,
8067                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
8068                Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
8069            };
8070            if kind.is_dir() && !checkouts.contains(&path) {
8071                directories.push(path);
8072            }
8073        }
8074    }
8075    Ok(None)
8076}
8077
8078pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
8079    if let Some(path) = unexpected_nested_git_entry(cwd)? {
8080        bail!(
8081            "the worktree contains a Git entry at {} that Git does not report as a nested \
8082             checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
8083             prove they are unchanged.",
8084            path.display()
8085        );
8086    }
8087    let root = std::fs::canonicalize(cwd)
8088        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8089    let mut repositories = BTreeMap::new();
8090    let mut visited = BTreeSet::new();
8091    collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
8092    Ok(GitState { repositories })
8093}
8094
8095fn collect_git_state(
8096    repository: &Path,
8097    prefix: &Path,
8098    visited: &mut BTreeSet<PathBuf>,
8099    repositories: &mut BTreeMap<PathBuf, RepositoryState>,
8100) -> Result<()> {
8101    let canonical = std::fs::canonicalize(repository)
8102        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
8103    if !visited.insert(canonical.clone()) {
8104        bail!("submodule recursion revisited {}", canonical.display());
8105    }
8106    let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8107    let head = head.trim().to_string();
8108    if head.is_empty() {
8109        bail!("git returned an empty head for {}", repository.display());
8110    }
8111    let unsafe_index_flags = unsafe_index_flags(repository)?;
8112    let tracked = tracked_entries(repository)?;
8113    let gitlinks = gitlinks(repository)?;
8114    if repositories
8115        .insert(
8116            prefix.to_path_buf(),
8117            RepositoryState {
8118                head,
8119                unsafe_index_flags,
8120                tracked,
8121                gitlinks: gitlinks
8122                    .iter()
8123                    .map(|link| (link.path.clone(), link.oid.clone()))
8124                    .collect(),
8125            },
8126        )
8127        .is_some()
8128    {
8129        bail!("Git state contains duplicate repository path {:?}", prefix);
8130    }
8131
8132    for link in gitlinks {
8133        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
8134            continue;
8135        };
8136        collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
8137    }
8138    Ok(())
8139}
8140
8141fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
8142    let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
8143    if !listed.is_empty() && !listed.ends_with(&[0]) {
8144        bail!(
8145            "git returned an unterminated index-flag listing for {}",
8146            cwd.display()
8147        );
8148    }
8149    let mut unsafe_records = Vec::new();
8150    for record in listed
8151        .split(|byte| *byte == 0)
8152        .filter(|record| !record.is_empty())
8153    {
8154        if record.len() < 3 || record[1] != b' ' {
8155            bail!(
8156                "git returned a malformed index-flag record for {}",
8157                cwd.display()
8158            );
8159        }
8160        if record[0] != b'H' {
8161            unsafe_records.extend_from_slice(record);
8162            unsafe_records.push(0);
8163        }
8164    }
8165    Ok(unsafe_records)
8166}
8167
8168pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
8169    safe_git_state(cwd).map(|_| ())
8170}
8171
8172pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
8173    let state = git_state(cwd)?;
8174    if let Some((path, _repository)) = state
8175        .repositories
8176        .iter()
8177        .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
8178    {
8179        let label = if path.as_os_str().is_empty() {
8180            cwd.to_path_buf()
8181        } else {
8182            cwd.join(path)
8183        };
8184        bail!(
8185            "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
8186             SPAR cannot prove the working files are unchanged, so it was kept.",
8187            label.display()
8188        );
8189    }
8190    Ok(state)
8191}
8192
8193fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
8194    repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
8195}
8196
8197fn repository_has_recoverable_work_with(
8198    cwd: &Path,
8199    include_ignored: bool,
8200    disposable: Disposable,
8201) -> Result<bool> {
8202    if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
8203        return Ok(true);
8204    }
8205    let mut visited = BTreeSet::new();
8206    repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
8207}
8208
8209fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
8210    let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
8211    let git_dir = PathBuf::from(git_dir.trim());
8212    let git_dir = if git_dir.is_absolute() {
8213        git_dir
8214    } else {
8215        cwd.join(git_dir)
8216    };
8217    let git_dir = std::fs::canonicalize(&git_dir)
8218        .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
8219    match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
8220        Ok(_) => return Ok(true),
8221        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8222        Err(error) => {
8223            return Err(spar_err!(
8224                "could not inspect per-worktree configuration in {}: {error}",
8225                git_dir.display()
8226            ))
8227        }
8228    }
8229
8230    let orig_head = git_dir.join("ORIG_HEAD");
8231    match std::fs::symlink_metadata(&orig_head) {
8232        Ok(metadata) if metadata.is_file() => {
8233            let oid = std::fs::read_to_string(&orig_head)
8234                .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
8235            let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
8236                return Ok(true);
8237            };
8238            if !commit_has_shared_ref(cwd, &commit)? {
8239                return Ok(true);
8240            }
8241        }
8242        Ok(_) => return Ok(true),
8243        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8244        Err(error) => {
8245            return Err(spar_err!(
8246                "could not inspect {}: {error}",
8247                orig_head.display()
8248            ))
8249        }
8250    }
8251
8252    let edit_message = git_dir.join("COMMIT_EDITMSG");
8253    match std::fs::symlink_metadata(&edit_message) {
8254        Ok(metadata) if metadata.is_file() => {
8255            let draft = std::fs::read(&edit_message)
8256                .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
8257            if draft != head_commit_message(cwd)? {
8258                return Ok(true);
8259            }
8260        }
8261        Ok(_) => return Ok(true),
8262        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8263        Err(error) => {
8264            return Err(spar_err!(
8265                "could not inspect {}: {error}",
8266                edit_message.display()
8267            ))
8268        }
8269    }
8270
8271    if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
8272        return Ok(true);
8273    }
8274
8275    let local_refs = run_git_bytes(
8276        cwd,
8277        &[
8278            "for-each-ref",
8279            "--format=%(refname)",
8280            "refs/worktree",
8281            "refs/bisect",
8282            "refs/rewritten",
8283        ],
8284    )?;
8285    if !local_refs.is_empty() {
8286        return Ok(true);
8287    }
8288
8289    // `FETCH_HEAD` only names what a `git fetch` in this worktree brought
8290    // down. The objects live in the shared store and the next fetch rewrites
8291    // it, so an agent that fetched once must not keep the checkout forever.
8292    for entry in std::fs::read_dir(&git_dir)
8293        .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
8294    {
8295        let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
8296        let known = matches!(
8297            entry.file_name().to_str(),
8298            Some(
8299                "HEAD"
8300                    | "ORIG_HEAD"
8301                    | "COMMIT_EDITMSG"
8302                    | "FETCH_HEAD"
8303                    | "commondir"
8304                    | "gitdir"
8305                    | "index"
8306                    | "logs"
8307                    | "refs"
8308            )
8309        );
8310        if !known {
8311            return Ok(true);
8312        }
8313    }
8314
8315    let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8316    if !commit_has_shared_ref(cwd, head.trim())? {
8317        return Ok(true);
8318    }
8319    Ok(false)
8320}
8321
8322fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
8323    let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
8324    let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
8325        bail!(
8326            "git returned a commit without a message separator in {}",
8327            cwd.display()
8328        );
8329    };
8330    Ok(commit[split + 2..].to_vec())
8331}
8332
8333fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
8334    let metadata = match std::fs::symlink_metadata(logs) {
8335        Ok(metadata) => metadata,
8336        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
8337        Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
8338    };
8339    if !metadata.is_dir() {
8340        return Ok(true);
8341    }
8342    let mut files = Vec::new();
8343    let mut directories = vec![logs.to_path_buf()];
8344    while let Some(directory) = directories.pop() {
8345        for entry in std::fs::read_dir(&directory)
8346            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
8347        {
8348            let entry =
8349                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
8350            let path = entry.path();
8351            let kind = entry
8352                .file_type()
8353                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
8354            if kind.is_dir() {
8355                directories.push(path);
8356            } else if kind.is_file() {
8357                files.push(path);
8358            } else {
8359                return Ok(true);
8360            }
8361        }
8362    }
8363
8364    let mut commits = BTreeSet::new();
8365    for path in files {
8366        if !collect_reflog_commits(cwd, &path, &mut commits)? {
8367            return Ok(true);
8368        }
8369    }
8370    for commit in commits {
8371        if !commit_has_shared_ref(cwd, &commit)? {
8372            return Ok(true);
8373        }
8374    }
8375    Ok(false)
8376}
8377
8378/// Every commit named by one ref's common reflog must survive deletion of that
8379/// ref. Ancestors of a durable current tip survive with the tip; divergent
8380/// entries need another shared ref of their own.
8381fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
8382    let common = common_git_dir(cwd)?;
8383    let reflog = common.join("logs").join(refname);
8384    let metadata = match std::fs::symlink_metadata(&reflog) {
8385        Ok(metadata) => metadata,
8386        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
8387        Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
8388    };
8389    if !metadata.is_file() {
8390        return Ok(false);
8391    }
8392    let mut commits = BTreeSet::new();
8393    if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
8394        return Ok(false);
8395    }
8396    for commit in commits {
8397        if is_ancestor(cwd, &commit, durable_tip)?
8398            || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
8399        {
8400            continue;
8401        }
8402        return Ok(false);
8403    }
8404    Ok(true)
8405}
8406
8407fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
8408    let file = std::fs::File::open(path)
8409        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8410    for line in std::io::BufReader::new(file).lines() {
8411        let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8412        let mut fields = line.splitn(3, ' ');
8413        let Some(old) = fields.next() else {
8414            return Ok(false);
8415        };
8416        let Some(new) = fields.next() else {
8417            return Ok(false);
8418        };
8419        if fields.next().is_none() {
8420            return Ok(false);
8421        }
8422        for oid in [old, new] {
8423            if oid.bytes().all(|byte| byte == b'0') {
8424                continue;
8425            }
8426            let Some(commit) = resolve_optional_commit(cwd, oid)? else {
8427                return Ok(false);
8428            };
8429            commits.insert(commit);
8430        }
8431    }
8432    Ok(true)
8433}
8434
8435fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
8436    let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
8437    let path = PathBuf::from(raw.trim());
8438    let path = if path.is_absolute() {
8439        path
8440    } else {
8441        cwd.join(path)
8442    };
8443    std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
8444}
8445
8446fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
8447    let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
8448    let output = proc::exec(
8449        &argv,
8450        &ExecOpts::new()
8451            .cwd(cwd)
8452            .timeout_secs(30)
8453            .check(false)
8454            .stop_descendants(true),
8455    )?;
8456    match output.code {
8457        0 => Ok(true),
8458        1 => Ok(false),
8459        _ => bail!("{}", proc::failure_message(&argv, &output)),
8460    }
8461}
8462
8463fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
8464    let commit = format!("{oid}^{{commit}}");
8465    let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
8466    let output = proc::exec(
8467        &argv,
8468        &ExecOpts::new()
8469            .cwd(cwd)
8470            .timeout_secs(30)
8471            .check(false)
8472            .stop_descendants(true),
8473    )?;
8474    if output.code != 0 {
8475        return Ok(None);
8476    }
8477    let oid = output.stdout.trim();
8478    if oid.is_empty() {
8479        return Ok(None);
8480    }
8481    Ok(Some(oid.to_string()))
8482}
8483
8484fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
8485    commit_has_shared_ref_except(cwd, oid, None)
8486}
8487
8488fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
8489    let contains = format!("--contains={oid}");
8490    let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
8491    Ok(shared.split(|byte| *byte == b'\n').any(|record| {
8492        !record.is_empty()
8493            && !record.starts_with(b"refs/worktree/")
8494            && !record.starts_with(b"refs/bisect/")
8495            && !record.starts_with(b"refs/rewritten/")
8496            && exclude.is_none_or(|excluded| record != excluded.as_bytes())
8497    }))
8498}
8499
8500/// Whether removing the worktree would take away an untracked file somebody
8501/// might want back.
8502///
8503/// Ordinary untracked files always count. So does an ignored file outside the
8504/// known build and cache directories, because an ignored path is only a path
8505/// Git was told not to track, which is where a local `.env` lives as readily as
8506/// compiler output.
8507///
8508/// Recognized build and cache output does not. A managed commit already leaves
8509/// it out rather than treating it as work, and the command that wrote it writes
8510/// it again. Counting it kept every worktree whose tests or build had run,
8511/// which is nearly all of them, so a merged pull request still left its
8512/// checkout behind. A repository nested in that output is somebody else's
8513/// history and counts whatever it sits under.
8514fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
8515    let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
8516    for raw in &ordinary {
8517        let (path, _) = untracked_record(raw, "untracked")?;
8518        if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
8519            continue;
8520        }
8521        return Ok(true);
8522    }
8523    let listed = untracked_listing(
8524        cwd,
8525        &[
8526            "ls-files",
8527            "--others",
8528            "--ignored",
8529            "--exclude-standard",
8530            "-z",
8531        ],
8532    )?;
8533    for raw in listed {
8534        let (path, nested) = untracked_record(&raw, "ignored")?;
8535        // A repository cloned into the output keeps the checkout whatever the
8536        // mode: its objects exist nowhere else, which is the one thing a
8537        // rebuild from origin cannot replace.
8538        if nested {
8539            return Ok(true);
8540        }
8541        if disposable == Disposable::BuildOutput
8542            && !is_generated_artifact(&path)
8543            && !is_desktop_metadata(&path)
8544        {
8545            return Ok(true);
8546        }
8547    }
8548    Ok(false)
8549}
8550
8551fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8552    let listed = run_git_bytes(cwd, args)?;
8553    if !listed.is_empty() && !listed.ends_with(&[0]) {
8554        bail!(
8555            "git returned an unterminated untracked-file list for {}",
8556            cwd.display()
8557        );
8558    }
8559    Ok(listed
8560        .split(|byte| *byte == 0)
8561        .filter(|raw| !raw.is_empty())
8562        .map(|raw| raw.to_vec())
8563        .collect())
8564}
8565
8566fn repository_has_recoverable_work_inner(
8567    cwd: &Path,
8568    include_ignored: bool,
8569    disposable: Disposable,
8570    visited: &mut BTreeSet<PathBuf>,
8571) -> Result<bool> {
8572    let canonical = std::fs::canonicalize(cwd)
8573        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8574    if !visited.insert(canonical.clone()) {
8575        bail!("submodule recursion revisited {}", canonical.display());
8576    }
8577    if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
8578        return Ok(true);
8579    }
8580    if !unsafe_index_flags(cwd)?.is_empty() {
8581        return Ok(true);
8582    }
8583    if attributes_may_be_modified(cwd)? {
8584        return Ok(true);
8585    }
8586    if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
8587        return Ok(true);
8588    }
8589    if include_ignored {
8590        let index = index_entries(cwd)?
8591            .into_iter()
8592            .map(|entry| (entry.path, (entry.mode, entry.oid)))
8593            .collect::<BTreeMap<_, _>>();
8594        let head = tree_entries(cwd, "HEAD")?
8595            .into_iter()
8596            .map(|entry| (entry.path, (entry.mode, entry.oid)))
8597            .collect::<BTreeMap<_, _>>();
8598        if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
8599            return Ok(true);
8600        }
8601        let tracked = tracked_entries(cwd)?;
8602        let effective = check_attributes(cwd, tracked.keys().cloned())?;
8603        let config = CheckoutConfig::read(cwd)?;
8604        for (path, entry) in tracked {
8605            let Some(worktree) = entry.worktree else {
8606                return Ok(true);
8607            };
8608            let attributes = effective.get(&path).ok_or_else(|| {
8609                spar_err!("git omitted attributes for {}", cwd.join(&path).display())
8610            })?;
8611            if path_has_ambiguous_transform(&config, attributes)? {
8612                return Ok(true);
8613            }
8614            let symlink_file = entry.index_mode == "120000"
8615                && worktree.mode == "100644"
8616                && worktree.raw_oid == entry.index_oid
8617                && config.symlinks == Some(false);
8618            if worktree.mode != entry.index_mode && !symlink_file {
8619                return Ok(true);
8620            }
8621            if entry.index_mode == "120000" {
8622                if worktree.raw_oid != entry.index_oid {
8623                    return Ok(true);
8624                }
8625                continue;
8626            }
8627            #[cfg(unix)]
8628            {
8629                let expected = if entry.index_mode == "100755" {
8630                    0o755
8631                } else {
8632                    0o644
8633                };
8634                if worktree.permissions != expected {
8635                    return Ok(true);
8636                }
8637            }
8638            if allows_expected_crlf(&config, attributes)? {
8639                let (normalized, every_lf_was_crlf) =
8640                    normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
8641                if !every_lf_was_crlf || normalized != entry.index_oid {
8642                    return Ok(true);
8643                }
8644            } else if worktree.raw_oid != entry.index_oid {
8645                return Ok(true);
8646            }
8647        }
8648    } else {
8649        let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
8650        if !run_git_bytes(cwd, &args)?.is_empty() {
8651            return Ok(true);
8652        }
8653    }
8654    for link in gitlinks(cwd)? {
8655        let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
8656            continue;
8657        };
8658        // Git stores a linked worktree's initialized submodule objects under
8659        // that worktree's administrative directory. Ordinary removal cannot
8660        // prove a local submodule commit exists anywhere else, even when both
8661        // working trees look clean.
8662        if include_ignored {
8663            return Ok(true);
8664        }
8665        let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8666        if head.trim() != link.oid {
8667            return Ok(true);
8668        }
8669        if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
8670        {
8671            return Ok(true);
8672        }
8673    }
8674    Ok(false)
8675}
8676
8677pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
8678    repository_has_recoverable_work(cwd, false)
8679}
8680
8681fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
8682    let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
8683    Ok(!run_git_bytes(cwd, &args)?.is_empty())
8684}
8685
8686#[cfg(unix)]
8687fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8688    use std::os::unix::ffi::OsStringExt;
8689    Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
8690}
8691
8692#[cfg(not(unix))]
8693fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8694    String::from_utf8(raw.to_vec())
8695        .map(PathBuf::from)
8696        .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
8697}
8698
8699/// Fingerprint the directory of a nested repository without reading inside it.
8700///
8701/// The files under it are that repository's, not this one's. They are recorded
8702/// against its own baseline whenever SPAR works there, and a run of its own may
8703/// legitimately add or remove entries while this call is in flight, so the
8704/// volatile directory fields stay out of the fingerprint. Identity and type
8705/// remain, which is what makes deleting the checkout, or replacing it with a
8706/// file, observable from the outer worktree.
8707/// What a path Git listed looks like once it is no longer there.
8708///
8709/// A reviewer building the project deletes and rewrites its output constantly,
8710/// so a path can go between the listing that named it and the stat that
8711/// measures it. Treating that as an unreadable worktree ended runs that had
8712/// nothing wrong with them. The record stays in the snapshot rather than being
8713/// dropped, so a path that vanishes cannot pass for one that was never there.
8714fn vanished_untracked_file() -> UntrackedFile {
8715    UntrackedFile {
8716        kind: 0,
8717        len: 0,
8718        modified: None,
8719        created: None,
8720        readonly: false,
8721        symlink_target: None,
8722        #[cfg(unix)]
8723        device: 0,
8724        #[cfg(unix)]
8725        inode: 0,
8726        #[cfg(unix)]
8727        mode: 0,
8728        #[cfg(unix)]
8729        change_seconds: 0,
8730        #[cfg(unix)]
8731        change_nanoseconds: 0,
8732    }
8733}
8734
8735fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
8736    let metadata = match std::fs::symlink_metadata(path) {
8737        Ok(metadata) => metadata,
8738        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8739        Err(e) => {
8740            return Err(spar_err!(
8741                "could not inspect the nested repository at {}: {e}",
8742                path.display()
8743            ))
8744        }
8745    };
8746    if !metadata.is_dir() {
8747        bail!(
8748            "git reported {} as a nested repository, but it is not a directory",
8749            path.display()
8750        );
8751    }
8752    if std::fs::symlink_metadata(path.join(".git")).is_err() {
8753        bail!(
8754            "git reported {} as a nested repository, but it has no Git entry",
8755            path.display()
8756        );
8757    }
8758    #[cfg(unix)]
8759    {
8760        use std::os::unix::fs::MetadataExt;
8761        Ok(UntrackedFile {
8762            kind: 3,
8763            len: 0,
8764            modified: None,
8765            created: metadata.created().ok(),
8766            readonly: metadata.permissions().readonly(),
8767            symlink_target: None,
8768            device: metadata.dev(),
8769            inode: metadata.ino(),
8770            mode: metadata.mode(),
8771            change_seconds: 0,
8772            change_nanoseconds: 0,
8773        })
8774    }
8775    #[cfg(not(unix))]
8776    {
8777        Ok(UntrackedFile {
8778            kind: 3,
8779            len: 0,
8780            modified: None,
8781            created: metadata.created().ok(),
8782            readonly: metadata.permissions().readonly(),
8783            symlink_target: None,
8784        })
8785    }
8786}
8787
8788fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
8789    let metadata = match std::fs::symlink_metadata(path) {
8790        Ok(metadata) => metadata,
8791        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8792        Err(e) => {
8793            return Err(spar_err!(
8794                "could not inspect untracked file {}: {e}",
8795                path.display()
8796            ))
8797        }
8798    };
8799    let kind = if metadata.file_type().is_symlink() {
8800        2
8801    } else if metadata.is_file() {
8802        1
8803    } else {
8804        bail!(
8805            "untracked path {} is not a regular file or symlink",
8806            path.display()
8807        );
8808    };
8809    let symlink_target = if kind == 2 {
8810        match std::fs::read_link(path) {
8811            Ok(target) => Some(os_str_bytes(target.as_os_str())?),
8812            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
8813                return Ok(vanished_untracked_file())
8814            }
8815            Err(e) => {
8816                return Err(spar_err!(
8817                    "could not read untracked symlink {}: {e}",
8818                    path.display()
8819                ))
8820            }
8821        }
8822    } else {
8823        None
8824    };
8825    #[cfg(unix)]
8826    {
8827        use std::os::unix::fs::MetadataExt;
8828        Ok(UntrackedFile {
8829            kind,
8830            len: metadata.len(),
8831            modified: metadata.modified().ok(),
8832            created: metadata.created().ok(),
8833            readonly: metadata.permissions().readonly(),
8834            symlink_target,
8835            device: metadata.dev(),
8836            inode: metadata.ino(),
8837            mode: metadata.mode(),
8838            change_seconds: metadata.ctime(),
8839            change_nanoseconds: metadata.ctime_nsec(),
8840        })
8841    }
8842    #[cfg(not(unix))]
8843    {
8844        Ok(UntrackedFile {
8845            kind,
8846            len: metadata.len(),
8847            modified: metadata.modified().ok(),
8848            created: metadata.created().ok(),
8849            readonly: metadata.permissions().readonly(),
8850            symlink_target,
8851        })
8852    }
8853}
8854
8855#[cfg(unix)]
8856fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8857    use std::os::unix::ffi::OsStrExt;
8858    Ok(value.as_bytes().to_vec())
8859}
8860
8861#[cfg(not(unix))]
8862fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8863    value
8864        .to_str()
8865        .map(|value| value.as_bytes().to_vec())
8866        .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
8867}
8868
8869#[cfg(unix)]
8870fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8871    use std::os::unix::fs::MetadataExt;
8872    right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
8873}
8874
8875#[cfg(not(unix))]
8876fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8877    right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
8878}
8879
8880#[derive(Debug, Clone, serde::Serialize, Deserialize)]
8881pub struct BranchRecord {
8882    pub kind: String,
8883    pub number: i64,
8884}
8885
8886/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
8887/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
8888pub fn review_ref(number: i64) -> String {
8889    format!("refs/spar/pr-{number}")
8890}
8891
8892pub fn is_finished(state: &str) -> bool {
8893    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
8894}
8895
8896/// Write text through a temporary file and rename, so a kill cannot leave a
8897/// truncated file behind.
8898///
8899/// The follow-up queue is the one file spar rewrites in place rather than
8900/// appends to, and a truncated queue is lost work: what it held was never
8901/// written anywhere else.
8902pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
8903    if let Some(parent) = path.parent() {
8904        std::fs::create_dir_all(parent)
8905            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
8906    }
8907    // The extension defaults to `json` so `clear_state`, which removes a
8908    // leftover `pr-N.json.tmp` by name, keeps finding the one this wrote.
8909    let tmp = path.with_extension(format!(
8910        "{}.tmp",
8911        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
8912    ));
8913    std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
8914    std::fs::rename(&tmp, path)
8915        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
8916    Ok(())
8917}
8918
8919/// Whether a follow-up file already has an entry under this exact title.
8920///
8921/// Whole heading lines, case insensitively. A substring check answered yes
8922/// for "Retry loop" against "## Retry loop never terminates", which is a
8923/// different defect, and the new one was dropped with a line saying it was
8924/// already noted.
8925pub fn has_heading(text: &str, title: &str) -> bool {
8926    let wanted = title.trim().to_lowercase();
8927    if wanted.is_empty() {
8928        return false;
8929    }
8930    text.lines()
8931        .filter_map(|line| line.trim().strip_prefix("## "))
8932        .any(|heading| heading.trim().to_lowercase() == wanted)
8933}
8934
8935/// Write JSON through a temporary file and rename, so a kill cannot leave a
8936/// truncated state file behind.
8937pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
8938    write_text_atomic(path, &serde_json::to_string_pretty(value)?)
8939}
8940
8941/// One row of `gh pr list --json number,url,title,baseRefName,closingIssuesReferences`.
8942#[derive(Deserialize)]
8943#[serde(rename_all = "camelCase")]
8944pub struct LinkedPrRow {
8945    number: i64,
8946    #[serde(default)]
8947    url: String,
8948    #[serde(default)]
8949    title: String,
8950    #[serde(default)]
8951    closing_issues_references: Vec<IssueRef>,
8952}
8953
8954/// The listed pull requests, or `None` when the payload could not be read.
8955///
8956/// Separated from the gh call so the real payload shape can be tested, and kept
8957/// distinct from an empty list, because silently failing to parse would look
8958/// exactly like "no pull request exists", which is the answer that makes spar
8959/// implement over the top of somebody's work.
8960pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
8961    serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
8962}
8963
8964/// Among the listed pull requests, the first that would close `issue`.
8965pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
8966    rows.iter()
8967        .find(|row| {
8968            row.closing_issues_references
8969                .iter()
8970                .any(|linked| linked.number == issue)
8971        })
8972        .map(|row| PrRef {
8973            number: row.number,
8974            url: row.url.clone(),
8975            title: row.title.clone(),
8976        })
8977}
8978
8979/// Both halves together, for the tests that hold the real payload.
8980pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
8981    pick_linked_pr(&linked_pr_rows(json)?, issue)
8982}
8983
8984/// Flatten whatever `gh api --paginate` printed into a list of comments.
8985///
8986/// Current gh merges array pages into one array. Older builds concatenated one
8987/// document per page. A streaming parser reads either, and unlike splitting the
8988/// text on a bracket pair it cannot be fooled by a comment body that happens to
8989/// contain one, which would otherwise make a resume silently start over.
8990pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
8991    if text.trim().is_empty() {
8992        return Err(spar_err!("GitHub returned no comment data"));
8993    }
8994    let mut out = Vec::new();
8995    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8996        match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
8997            Value::Array(items) => out.extend(items),
8998            _ => return Err(spar_err!("unexpected non-array comment page")),
8999        }
9000    }
9001    Ok(out)
9002}
9003
9004pub fn parse_comment_pages(text: &str) -> Vec<Value> {
9005    let mut out = Vec::new();
9006    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
9007        match value {
9008            Ok(Value::Array(items)) => out.extend(items),
9009            Ok(other) => out.push(other),
9010            Err(_) => break,
9011        }
9012    }
9013    out
9014}
9015
9016/// Whether a comment is a state block spar itself wrote.
9017///
9018/// The body has to open with the marker, so a person quoting it in a sentence
9019/// is not one; it has to parse, so a half-quoted block is not one; and it has
9020/// to be the viewer's own comment, the way `comments.rs` already tells spar's
9021/// writes apart. Anything less and `write_pr_state` patches somebody's comment
9022/// with JSON, and `spar clean --pr-state` later deletes it.
9023pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
9024    body.trim_start().starts_with(STATE_MARKER)
9025        && !viewer.trim().is_empty()
9026        && author.trim().eq_ignore_ascii_case(viewer.trim())
9027        && parse_state_comment(body).is_some()
9028}
9029
9030/// Extract the payload from a state comment. The marker is followed by JSON and
9031/// terminated with `-->`.
9032pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
9033    let marker = body.find(STATE_MARKER)?;
9034    let start = body[marker..].find('{')? + marker;
9035    let end = body.rfind('}')?;
9036    if end <= start {
9037        return None;
9038    }
9039    match serde_json::from_str(&body[start..=end]) {
9040        Ok(state) => Some(state),
9041        Err(_) => {
9042            logdim!("found a spar state comment but could not parse it");
9043            None
9044        }
9045    }
9046}
9047
9048fn choose_state_for_head(
9049    candidates: Vec<PersistedState>,
9050    actual_head: &str,
9051) -> Option<PersistedState> {
9052    let matching: Vec<PersistedState> = candidates
9053        .iter()
9054        .filter(|state| state.pr_head == actual_head)
9055        .cloned()
9056        .collect();
9057    if !matching.is_empty() {
9058        return newest_state(matching);
9059    }
9060    newest_state(candidates)
9061}
9062
9063fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
9064    candidates.into_iter().reduce(|best, candidate| {
9065        if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
9066            candidate
9067        } else {
9068            // The local candidate is supplied first. Keeping the first exact
9069            // tie recovers correctly from a local write followed by a failed
9070            // pull request state update, including legacy states with no
9071            // checkpoint field.
9072            best
9073        }
9074    })
9075}
9076
9077/// Where this binary lives, so `git filter-branch` can call back into it.
9078///
9079/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
9080/// tests, whose `current_exe` is the test harness rather than spar, and for
9081/// anyone who ships spar behind a wrapper script.
9082pub fn self_binary() -> Result<PathBuf> {
9083    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
9084        let path = PathBuf::from(path);
9085        if proc::is_executable(&path) {
9086            return Ok(path);
9087        }
9088        bail!(
9089            "SPAR_SELF_BIN is set to {}, which is not executable",
9090            path.display()
9091        );
9092    }
9093    std::env::current_exe()
9094        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
9095}
9096
9097fn bool_env(value: bool) -> &'static str {
9098    if value {
9099        "1"
9100    } else {
9101        "0"
9102    }
9103}
9104
9105/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
9106/// shell command, and an install path with a space in it is not exotic.
9107pub fn sh_quote(text: &str) -> String {
9108    format!("'{}'", text.replace('\'', r"'\''"))
9109}
9110
9111/// Style rules for the `scrub-filter` subcommand, which runs in a child process
9112/// spawned by git and so cannot see the parent's config.
9113pub fn style_from_env() -> Style {
9114    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
9115    Style {
9116        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
9117        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
9118        ..Style::permissive()
9119    }
9120}
9121
9122#[cfg(test)]
9123mod tests {
9124    use super::*;
9125    use crate::config::StateStore;
9126    use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
9127    use std::process::Command;
9128
9129    fn repo_for_titles() -> Repo {
9130        Repo {
9131            root: PathBuf::from("/nonexistent"),
9132            style: Style::default(),
9133            branch_prefix: String::new(),
9134            state_store: StateStore::Local,
9135            followups: crate::config::Followups::Issues,
9136            drafts: Drafts::Never,
9137            fork_when_denied: true,
9138            setup: CheckCommand::default(),
9139            isolate_reads: true,
9140            keep_worktrees: false,
9141            fork: OnceLock::new(),
9142            viewer: OnceLock::new(),
9143            checkpoints: Mutex::new(BTreeMap::new()),
9144            writes: WriteStats::default(),
9145        }
9146    }
9147
9148    /// The sweep deletes on what this says, so a name it does not recognise
9149    /// is no read checkout at all.
9150    #[test]
9151    fn only_a_read_checkout_name_names_its_owner() {
9152        assert_eq!(Some(4242), read_checkout_owner("read-4242-0"));
9153        assert_eq!(None, read_checkout_owner("read-4242"));
9154        assert_eq!(None, read_checkout_owner("read-me-0"));
9155        assert_eq!(None, read_checkout_owner("read-4242-x"));
9156        assert_eq!(None, read_checkout_owner("review-4242"));
9157    }
9158
9159    #[test]
9160    fn write_results_accumulate_for_the_run() {
9161        let repo = repo_for_titles();
9162
9163        let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
9164        let _: std::result::Result<(), ()> = repo.record_write(Err(()));
9165
9166        assert_eq!(
9167            WriteSummary {
9168                attempted: 2,
9169                failed: 1,
9170            },
9171            repo.write_summary()
9172        );
9173    }
9174
9175    #[test]
9176    fn only_failed_write_preflights_join_the_summary() {
9177        let repo = repo_for_titles();
9178
9179        let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
9180        let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
9181
9182        assert_eq!(
9183            WriteSummary {
9184                attempted: 1,
9185                failed: 1,
9186            },
9187            repo.write_summary()
9188        );
9189    }
9190
9191    #[test]
9192    fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
9193        let repo = repo_for_titles();
9194
9195        assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
9196        assert_eq!(
9197            WriteSummary {
9198                attempted: 1,
9199                failed: 1,
9200            },
9201            repo.write_summary()
9202        );
9203    }
9204
9205    #[test]
9206    fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
9207        let mut repo = repo_for_titles();
9208        repo.followups = Followups::Local;
9209
9210        assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
9211        assert_eq!(WriteSummary::default(), repo.write_summary());
9212    }
9213
9214    #[test]
9215    fn a_failed_remote_state_read_stops_before_state_mutation() {
9216        let root = std::env::temp_dir().join(format!(
9217            "spar-state-preflight-{}-{}",
9218            std::process::id(),
9219            std::time::SystemTime::now()
9220                .duration_since(std::time::UNIX_EPOCH)
9221                .unwrap()
9222                .as_nanos()
9223        ));
9224        std::fs::create_dir_all(&root).unwrap();
9225        let _fixture = ReviewFixture { root: root.clone() };
9226        let mut repo = repo_for_titles();
9227        repo.root = root;
9228        repo.state_store = StateStore::Both;
9229        let state = PersistedState {
9230            version: 1,
9231            checkpoint: 4,
9232            round: 2,
9233            next_actor: "a".into(),
9234            status: Status::Pending,
9235            pr_head: "abc123".into(),
9236            ledger: Ledger::new(),
9237            filed: Vec::new(),
9238            open_findings: Vec::new(),
9239            disputes: Vec::new(),
9240            noted: Vec::new(),
9241        };
9242
9243        let error = repo
9244            .write_state_after_remote_read(
9245                7,
9246                &state,
9247                Err(crate::error::SparError::new("state comments unavailable")),
9248            )
9249            .unwrap_err();
9250
9251        assert!(error.to_string().contains("state comments unavailable"));
9252        assert!(!repo.state_path(7).exists());
9253        assert_eq!(0, repo.remembered_checkpoint(7));
9254        assert_eq!(
9255            WriteSummary {
9256                attempted: 1,
9257                failed: 1,
9258            },
9259            repo.write_summary()
9260        );
9261    }
9262
9263    #[test]
9264    fn only_known_build_and_cache_directories_are_generated_artifacts() {
9265        assert!(is_generated_artifact(Path::new("target/debug/artifact")));
9266        assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
9267        assert!(is_generated_artifact(Path::new(
9268            "package/node_modules/dependency/file.js"
9269        )));
9270        assert!(!is_generated_artifact(Path::new(
9271            "distribution/required-package.js"
9272        )));
9273        assert!(!is_generated_artifact(Path::new(
9274            "generated/required-fixture.txt"
9275        )));
9276        assert!(!is_generated_artifact(Path::new("local.env")));
9277    }
9278
9279    #[test]
9280    fn only_finder_state_files_are_desktop_metadata() {
9281        assert!(is_desktop_metadata(Path::new(".DS_Store")));
9282        assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
9283        assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
9284        assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
9285    }
9286
9287    struct ReviewFixture {
9288        root: PathBuf,
9289    }
9290
9291    impl Drop for ReviewFixture {
9292        fn drop(&mut self) {
9293            let _ = std::fs::remove_dir_all(&self.root);
9294        }
9295    }
9296
9297    fn test_git(cwd: &Path, args: &[&str]) -> String {
9298        let output = Command::new("git")
9299            .args(args)
9300            .current_dir(cwd)
9301            .output()
9302            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
9303        assert!(
9304            output.status.success(),
9305            "git {args:?} failed: {}",
9306            String::from_utf8_lossy(&output.stderr)
9307        );
9308        String::from_utf8_lossy(&output.stdout).into_owned()
9309    }
9310
9311    /// The diff a line comment is checked against is parsed from git's output,
9312    /// so a user setting that changes that output must not change the answer,
9313    /// and a text converter the branch could have changed must never run.
9314    #[test]
9315    fn the_lines_a_comment_can_go_on_ignore_how_the_user_has_git_print_a_diff() {
9316        let root =
9317            std::env::temp_dir().join(format!("spar-repo-test-hunks-{}", std::process::id()));
9318        let _cleanup = ReviewFixture { root: root.clone() };
9319        let origin = root.join("origin.git");
9320        let work = root.join("work");
9321        let marker = root.join("textconv-ran");
9322        std::fs::create_dir_all(&origin).unwrap();
9323        std::fs::create_dir_all(&work).unwrap();
9324        test_git(&origin, &["init", "--bare", "-b", "main"]);
9325        test_git(&work, &["init", "-b", "main"]);
9326        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9327        test_git(&work, &["config", "user.name", "spar test"]);
9328        test_git(&work, &["config", "commit.gpgsign", "false"]);
9329        test_git(&work, &["config", "diff.noprefix", "true"]);
9330        test_git(&work, &["config", "diff.context", "0"]);
9331        test_git(&work, &["config", "diff.interHunkContext", "10"]);
9332        test_git(&work, &["config", "diff.algorithm", "patience"]);
9333        test_git(&work, &["config", "color.diff", "always"]);
9334        test_git(
9335            &work,
9336            &[
9337                "config",
9338                "diff.conv.textconv",
9339                &format!("touch {}; cat", marker.display()),
9340            ],
9341        );
9342        std::fs::write(work.join(".git/info/attributes"), "*.txt diff=conv\n").unwrap();
9343        let lines: String = (1..=20).map(|n| format!("line {n}\n")).collect();
9344        std::fs::write(work.join("src.txt"), &lines).unwrap();
9345        test_git(&work, &["add", "."]);
9346        test_git(&work, &["commit", "-m", "seed"]);
9347        test_git(
9348            &work,
9349            &["remote", "add", "origin", origin.to_str().unwrap()],
9350        );
9351        test_git(&work, &["push", "-u", "origin", "main"]);
9352        test_git(&work, &["checkout", "-b", "feature"]);
9353        std::fs::write(
9354            work.join("src.txt"),
9355            lines
9356                .replace("line 5\n", "changed 5\n")
9357                .replace("line 15\n", "changed 15\n"),
9358        )
9359        .unwrap();
9360        std::fs::write(work.join("new.txt"), "a\nb\n").unwrap();
9361        std::fs::write(work.join("with space.txt"), "a\n").unwrap();
9362        test_git(&work, &["add", "."]);
9363        test_git(&work, &["commit", "-m", "change"]);
9364        let cfg = crate::config::parse(
9365            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9366        )
9367        .unwrap();
9368        let repo = Repo::open(&work, &cfg).unwrap();
9369        assert_eq!(
9370            Hunks::from([
9371                ("new.txt".to_string(), vec![(1, 2)]),
9372                ("src.txt".to_string(), vec![(2, 8), (12, 18)]),
9373                ("with space.txt".to_string(), vec![(1, 1)]),
9374            ]),
9375            repo.diff_hunks(&work, "main")
9376        );
9377        assert!(!marker.exists(), "the text converter ran");
9378        // The converter is real: a plain diff with the same settings runs it.
9379        test_git(&work, &["diff", "origin/main...HEAD"]);
9380        assert!(marker.exists(), "the fixture's converter never runs at all");
9381    }
9382
9383    fn review_fixture(
9384        tag: &str,
9385        number: i64,
9386    ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
9387        use std::sync::atomic::{AtomicU32, Ordering};
9388        static NEXT: AtomicU32 = AtomicU32::new(0);
9389        let id = NEXT.fetch_add(1, Ordering::Relaxed);
9390        let root =
9391            std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
9392        let origin = root.join("origin.git");
9393        let work = root.join("work");
9394        std::fs::create_dir_all(&origin).unwrap();
9395        std::fs::create_dir_all(&work).unwrap();
9396        test_git(&origin, &["init", "--bare", "-b", "main"]);
9397        test_git(&work, &["init", "-b", "main"]);
9398        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9399        test_git(&work, &["config", "user.name", "spar test"]);
9400        test_git(&work, &["config", "commit.gpgsign", "false"]);
9401        test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
9402        test_git(&work, &["config", "filter.drop.smudge", "cat"]);
9403        std::fs::write(work.join("README.md"), "seed\n").unwrap();
9404        std::fs::write(work.join("data.txt"), "old\n").unwrap();
9405        std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
9406        std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
9407        test_git(&work, &["add", "."]);
9408        test_git(&work, &["commit", "-m", "seed"]);
9409        test_git(
9410            &work,
9411            &["remote", "add", "origin", origin.to_str().unwrap()],
9412        );
9413        test_git(&work, &["push", "-u", "origin", "main"]);
9414        test_git(
9415            &work,
9416            &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
9417        );
9418        let cfg = crate::config::parse(
9419            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9420        )
9421        .unwrap();
9422        let repo = Repo::open(&work, &cfg).unwrap();
9423        let path = repo.worktree_for_pr_head(number).unwrap();
9424        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9425        (ReviewFixture { root }, repo, path, checkpoint)
9426    }
9427
9428    #[test]
9429    fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
9430        let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
9431
9432        repo.release_review_worktree_checked(901, &checkpoint)
9433            .unwrap();
9434
9435        assert!(!path.exists());
9436    }
9437
9438    #[test]
9439    fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
9440        let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
9441        let work = fixture.root.join("work");
9442        let seed = test_git(&work, &["rev-parse", "HEAD"]);
9443        let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
9444        // Heads only the pull request holds, the second rewritten rather than
9445        // built on the first, as a rebase and force push leave them.
9446        let publish = |message: &str| {
9447            let oid = test_git(
9448                &work,
9449                &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
9450            );
9451            let oid = oid.trim().to_string();
9452            test_git(
9453                &work,
9454                &[
9455                    "push",
9456                    "--force",
9457                    "origin",
9458                    &format!("{oid}:refs/pull/923/head"),
9459                ],
9460            );
9461            oid
9462        };
9463        let reviewed = publish("reviewed head");
9464        repo.worktree_for_pr_head(923).unwrap();
9465        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
9466
9467        let rewritten = publish("rewritten head");
9468        repo.worktree_for_pr_head(923).unwrap();
9469
9470        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
9471    }
9472
9473    #[test]
9474    fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
9475        let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
9476        let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
9477        std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
9478        test_git(&path, &["add", "recovery.txt"]);
9479        test_git(&path, &["commit", "-m", "recovery commit"]);
9480        let recovery = test_git(&path, &["rev-parse", "HEAD"]);
9481        test_git(&path, &["reset", "--hard", "main"]);
9482
9483        assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
9484        test_git(
9485            &path,
9486            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9487        );
9488    }
9489
9490    #[test]
9491    fn a_review_ref_reflog_only_commit_prevents_deletion() {
9492        let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
9493        let local_ref = review_ref(921);
9494        let original = test_git(&path, &["rev-parse", &local_ref]);
9495        let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
9496        let recovery = test_git(
9497            &path,
9498            &[
9499                "commit-tree",
9500                tree.trim(),
9501                "-p",
9502                original.trim(),
9503                "-m",
9504                "review ref recovery",
9505            ],
9506        );
9507        test_git(
9508            &path,
9509            &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
9510        );
9511        test_git(
9512            &path,
9513            &["update-ref", &local_ref, original.trim(), recovery.trim()],
9514        );
9515
9516        assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
9517        assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
9518        test_git(
9519            &path,
9520            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9521        );
9522    }
9523
9524    #[test]
9525    fn an_unpublished_commit_message_draft_is_recoverable() {
9526        let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
9527        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9528        let git_dir = if raw.is_absolute() {
9529            raw
9530        } else {
9531            path.join(raw)
9532        };
9533        std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
9534
9535        assert!(repository_has_recoverable_work(&path, true).unwrap());
9536        assert_eq!(
9537            "unique recovery draft\n",
9538            std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
9539        );
9540    }
9541
9542    #[test]
9543    fn a_changed_review_worktree_is_retained_after_a_checked_read() {
9544        let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
9545        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9546
9547        let error = repo
9548            .release_review_worktree_checked(902, &checkpoint)
9549            .unwrap_err();
9550
9551        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9552        assert!(error.to_string().contains("kept for recovery"), "{error}");
9553        assert_eq!(
9554            "recover me\n",
9555            std::fs::read_to_string(path.join("README.md")).unwrap()
9556        );
9557        repo.release_review_worktree(902);
9558    }
9559
9560    #[test]
9561    fn a_review_commit_is_retained_after_a_checked_read() {
9562        let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
9563        std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
9564        test_git(&path, &["add", "review-note.txt"]);
9565        test_git(&path, &["commit", "-m", "local review recovery"]);
9566        let head = test_git(&path, &["rev-parse", "HEAD"]);
9567
9568        let error = repo
9569            .release_review_worktree_checked(903, &checkpoint)
9570            .unwrap_err();
9571
9572        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9573        assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
9574        assert_eq!(
9575            "recover me\n",
9576            std::fs::read_to_string(path.join("review-note.txt")).unwrap()
9577        );
9578        repo.release_review_worktree(903);
9579    }
9580
9581    /// The read is no longer what refuses: a file the call created and Git was
9582    /// told to ignore is its own output. Removal still refuses, so the file is
9583    /// there to look at, which is the part that mattered.
9584    #[test]
9585    fn an_ignored_review_file_is_retained_after_a_checked_read() {
9586        let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
9587        std::fs::create_dir_all(path.join("generated")).unwrap();
9588        std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
9589
9590        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9591            .expect("its own output is not a change");
9592        let error = repo
9593            .release_review_worktree_checked(904, &checkpoint)
9594            .unwrap_err();
9595
9596        assert!(error.to_string().contains("recoverable"), "{error}");
9597        assert_eq!(
9598            "recover me\n",
9599            std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
9600        );
9601        repo.release_review_worktree(904);
9602    }
9603
9604    #[test]
9605    /// The checkpoint no longer refuses this: a path the project ignores is out
9606    /// of scope for a call that only read. What keeps the file is the gate that
9607    /// actually deletes the checkout, which refuses any ignored file outside a
9608    /// known build directory. That is the check with the stake in it, and it is
9609    /// the one that has to hold.
9610    fn a_preexisting_ignored_review_file_change_is_retained() {
9611        let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
9612        std::fs::create_dir_all(path.join("generated")).unwrap();
9613        let ignored = path.join("generated/recovery.txt");
9614        std::fs::write(&ignored, "before\n").unwrap();
9615        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9616        std::fs::write(&ignored, "after!\n").unwrap();
9617
9618        let error = repo
9619            .release_review_worktree_checked(905, &checkpoint)
9620            .unwrap_err();
9621
9622        assert!(error.to_string().contains("recoverable"), "{error}");
9623        assert!(path.exists());
9624        assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
9625        repo.release_review_worktree(905);
9626    }
9627
9628    #[test]
9629    fn a_preexisting_ignored_review_file_prevents_checked_removal() {
9630        let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
9631        std::fs::create_dir_all(path.join("generated")).unwrap();
9632        let ignored = path.join("generated/recovery.txt");
9633        std::fs::write(&ignored, "keep me\n").unwrap();
9634        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9635
9636        let error = repo
9637            .release_review_worktree_checked(906, &checkpoint)
9638            .unwrap_err();
9639
9640        assert!(error.to_string().contains("recoverable"), "{error}");
9641        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
9642    }
9643
9644    #[test]
9645    fn overwriting_a_preexisting_untracked_file_is_detected() {
9646        let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
9647        let untracked = path.join("notes.txt");
9648        std::fs::write(&untracked, "before\n").unwrap();
9649        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9650        std::fs::write(&untracked, "after!\n").unwrap();
9651
9652        let error = repo
9653            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9654            .unwrap_err();
9655
9656        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9657        assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
9658    }
9659
9660    #[test]
9661    fn an_assume_unchanged_edit_is_detected() {
9662        let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
9663        test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
9664        std::fs::write(path.join("README.md"), "hidden\n").unwrap();
9665
9666        let error = repo
9667            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9668            .unwrap_err();
9669
9670        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9671        assert_eq!(
9672            "hidden\n",
9673            std::fs::read_to_string(path.join("README.md")).unwrap()
9674        );
9675    }
9676
9677    #[test]
9678    fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
9679        let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
9680        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9681        test_git(&path, &["add", "README.md"]);
9682        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9683
9684        let error = repo
9685            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9686            .unwrap_err();
9687
9688        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9689        assert_eq!(
9690            b"seed\r\n",
9691            std::fs::read(path.join("README.md")).unwrap().as_slice()
9692        );
9693    }
9694
9695    #[cfg(unix)]
9696    #[test]
9697    fn a_mode_edit_is_detected_when_filemode_is_disabled() {
9698        use std::os::unix::fs::PermissionsExt;
9699
9700        let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
9701        test_git(&path, &["config", "core.filemode", "false"]);
9702        let readme = path.join("README.md");
9703        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9704        permissions.set_mode(0o755);
9705        std::fs::set_permissions(&readme, permissions).unwrap();
9706        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9707
9708        let error = repo
9709            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9710            .unwrap_err();
9711
9712        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9713        assert_eq!(
9714            0o755,
9715            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9716        );
9717    }
9718
9719    #[test]
9720    fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
9721        let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
9722        std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
9723        test_git(&path, &["add", ".gitattributes"]);
9724        test_git(&path, &["commit", "-m", "select data filter"]);
9725        let baseline = repo.worktree_baseline(&path).unwrap();
9726        std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
9727
9728        assert!(repo
9729            .commit_pending_changes(&path, &baseline, "change data", "change data")
9730            .unwrap());
9731        let error = repo
9732            .refuse_unrepresented_tracked_changes(&path, &baseline)
9733            .unwrap_err();
9734
9735        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9736        assert_eq!(
9737            "secret: recover me\nnew\n",
9738            std::fs::read_to_string(path.join("data.txt")).unwrap()
9739        );
9740        assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
9741    }
9742
9743    #[test]
9744    fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
9745        let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
9746        std::fs::create_dir_all(path.join("target")).unwrap();
9747        let untracked = path.join("target/user.yaml");
9748        std::fs::write(&untracked, "user data\n").unwrap();
9749        let baseline = repo.worktree_baseline(&path).unwrap();
9750        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9751
9752        assert!(repo
9753            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9754            .unwrap());
9755
9756        assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
9757        assert_eq!(
9758            "?? target/user.yaml\n",
9759            test_git(&path, &["status", "--short", "--untracked-files=all"])
9760        );
9761        assert!(test_git(
9762            &path,
9763            &[
9764                "ls-tree",
9765                "-r",
9766                "--name-only",
9767                "HEAD",
9768                "--",
9769                "target/user.yaml"
9770            ]
9771        )
9772        .is_empty());
9773    }
9774
9775    #[test]
9776    fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
9777        let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
9778        std::fs::create_dir_all(path.join("target")).unwrap();
9779        let untracked = path.join("target/user.yaml");
9780        std::fs::write(&untracked, "before\n").unwrap();
9781        let baseline = repo.worktree_baseline(&path).unwrap();
9782        let before = test_git(&path, &["rev-parse", "HEAD"]);
9783        std::fs::write(&untracked, "after\n").unwrap();
9784        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9785
9786        let error = repo
9787            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9788            .unwrap_err();
9789
9790        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9791        assert!(error.to_string().contains("target/user.yaml"), "{error}");
9792        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
9793        assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
9794        assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
9795    }
9796
9797    #[test]
9798    fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
9799        let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
9800        let baseline = repo.worktree_baseline(&path).unwrap();
9801        std::fs::create_dir_all(path.join("target")).unwrap();
9802        std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
9803
9804        assert!(repo
9805            .commit_pending_changes(&path, &baseline, "add file", "add file")
9806            .unwrap());
9807
9808        assert_eq!(
9809            "new file\n",
9810            test_git(&path, &["show", "HEAD:target/new.txt"])
9811        );
9812        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9813    }
9814
9815    #[test]
9816    fn a_managed_commit_accepts_a_custom_output_rebuild() {
9817        let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
9818        exclude_paths(&repo, &["manager/public/"]);
9819        let output = path.join("manager/public/assets");
9820        std::fs::create_dir_all(&output).unwrap();
9821        std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
9822        std::fs::write(output.join("index.css"), "old styles\n").unwrap();
9823        let baseline = repo.worktree_baseline(&path).unwrap();
9824        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9825        std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
9826        std::fs::remove_file(output.join("old.js")).unwrap();
9827        std::fs::write(output.join("index.css"), "new styles\n").unwrap();
9828        std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
9829
9830        assert!(repo
9831            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9832            .unwrap());
9833        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9834        repo.refuse_changed_existing_untracked(&path, &baseline)
9835            .unwrap();
9836        repo.refuse_unrepresented_tracked_changes(&path, &baseline)
9837            .unwrap();
9838
9839        assert_eq!(
9840            "tracked change\n",
9841            test_git(&path, &["show", "HEAD:README.md"])
9842        );
9843        assert_eq!(
9844            "new source\n",
9845            test_git(&path, &["show", "HEAD:new-source.txt"])
9846        );
9847        assert!(test_git(
9848            &path,
9849            &[
9850                "ls-tree",
9851                "-r",
9852                "--name-only",
9853                "HEAD",
9854                "--",
9855                "manager/public"
9856            ]
9857        )
9858        .is_empty());
9859        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9860        assert!(!output.join("old.js").exists());
9861        assert_eq!(
9862            "new styles\n",
9863            std::fs::read_to_string(output.join("index.css")).unwrap()
9864        );
9865        assert!(repository_has_recoverable_work(&path, true).unwrap());
9866        repo.release_review_worktree(912);
9867        assert!(output.join("index-DUym-Rfj.js").exists());
9868    }
9869
9870    #[test]
9871    fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
9872        let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
9873        std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
9874        let baseline = repo.worktree_baseline(&path).unwrap();
9875        std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
9876        std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
9877
9878        let error = repo
9879            .commit_pending_changes(&path, &baseline, "change", "change")
9880            .unwrap_err();
9881
9882        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9883        assert!(error.to_string().contains("notes.txt"), "{error}");
9884    }
9885
9886    #[test]
9887    fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
9888        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
9889        let baseline = repo.worktree_baseline(&path).unwrap();
9890        let nested = path.join("generated/repro");
9891        std::fs::create_dir_all(&nested).unwrap();
9892        test_git(&nested, &["init"]);
9893        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9894
9895        assert!(repo
9896            .commit_pending_changes(&path, &baseline, "change", "change")
9897            .unwrap());
9898        assert!(!repo.has_uncommitted_changes(&path).unwrap());
9899        assert!(repository_has_recoverable_work(&path, true).unwrap());
9900        repo.release_review_worktree(927);
9901        assert!(nested.join(".git").exists());
9902    }
9903
9904    #[test]
9905    fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
9906        let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
9907        test_git(&path, &["config", "core.autocrlf", "true"]);
9908        std::fs::write(path.join("README.md"), "seed\n").unwrap();
9909        assert_eq!(
9910            test_git(&path, &["hash-object", "README.md"]).trim(),
9911            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9912        );
9913
9914        assert!(repository_has_recoverable_work(&path, true).unwrap());
9915        assert_eq!(
9916            "seed\n",
9917            std::fs::read_to_string(path.join("README.md")).unwrap()
9918        );
9919    }
9920
9921    #[test]
9922    fn autocrlf_input_overrides_a_crlf_core_eol() {
9923        let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
9924        test_git(&path, &["config", "core.autocrlf", "input"]);
9925        test_git(&path, &["config", "core.eol", "crlf"]);
9926        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9927        assert_eq!(
9928            test_git(&path, &["hash-object", "README.md"]).trim(),
9929            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9930        );
9931
9932        assert!(repository_has_recoverable_work(&path, true).unwrap());
9933        assert_eq!(
9934            b"seed\r\n",
9935            std::fs::read(path.join("README.md")).unwrap().as_slice()
9936        );
9937    }
9938
9939    #[cfg(unix)]
9940    #[test]
9941    fn a_non_executable_permission_change_is_recoverable() {
9942        use std::os::unix::fs::PermissionsExt;
9943
9944        let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
9945        let readme = path.join("README.md");
9946        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9947        permissions.set_mode(0o600);
9948        std::fs::set_permissions(&readme, permissions).unwrap();
9949        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9950
9951        let error = repo
9952            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9953            .unwrap_err();
9954
9955        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9956        assert!(repository_has_recoverable_work(&path, true).unwrap());
9957        assert_eq!(
9958            0o600,
9959            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9960        );
9961    }
9962
9963    #[cfg(unix)]
9964    #[test]
9965    fn a_managed_commit_skips_signing_and_hooks() {
9966        use std::os::unix::fs::PermissionsExt;
9967
9968        let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
9969        let common = common_git_dir(&path).unwrap();
9970        let hook = common.join("hooks/pre-commit");
9971        let marker = fixture.root.join("hook-ran");
9972        std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
9973        std::fs::write(
9974            &hook,
9975            format!(
9976                "#!/bin/sh\nprintf ran > {}\nexit 1\n",
9977                sh_quote(marker.to_str().unwrap())
9978            ),
9979        )
9980        .unwrap();
9981        let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
9982        permissions.set_mode(0o755);
9983        std::fs::set_permissions(&hook, permissions).unwrap();
9984        test_git(&path, &["config", "commit.gpgsign", "true"]);
9985        test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
9986        std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
9987        test_git(&path, &["add", "managed.txt"]);
9988
9989        repo.commit_staged_changes(&path, "record managed change")
9990            .unwrap();
9991
9992        assert!(!marker.exists());
9993        assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
9994    }
9995
9996    #[test]
9997    fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
9998        let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
9999        std::fs::write(
10000            path.join(".gitattributes"),
10001            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
10002        )
10003        .unwrap();
10004        test_git(&path, &["add", ".gitattributes"]);
10005        test_git(&path, &["commit", "-m", "select automatic text"]);
10006        test_git(&path, &["config", "core.autocrlf", "true"]);
10007
10008        assert!(repository_has_recoverable_work(&path, true).unwrap());
10009    }
10010
10011    #[test]
10012    fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
10013        let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
10014        std::fs::write(
10015            path.join(".gitattributes"),
10016            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
10017        )
10018        .unwrap();
10019        test_git(&path, &["add", ".gitattributes"]);
10020        test_git(&path, &["commit", "-m", "select ident expansion"]);
10021        std::fs::write(path.join("README.md"), "seed\n").unwrap();
10022
10023        assert!(repository_has_recoverable_work(&path, true).unwrap());
10024    }
10025
10026    /// Add ignore rules the way SPAR does, without a tracked change the
10027    /// worktree would then be kept for.
10028    /// The failure this whole rule was rewritten for: a reviewer ran the
10029    /// project's binding generator to check a finding, and the lockfile it left
10030    /// beside its own manifest matched no directory name spar knew.
10031    #[test]
10032    fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
10033        let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
10034        exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
10035        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10036        std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
10037        std::fs::write(
10038            path.join("bindings/uniffi-bindgen/Cargo.lock"),
10039            "# generated\n",
10040        )
10041        .unwrap();
10042
10043        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10044            .expect("a path the project ignores is the call's own output");
10045    }
10046
10047    #[test]
10048    fn an_attribute_file_under_build_output_is_not_a_filter_change() {
10049        let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
10050        exclude_paths(&repo, &["target/"]);
10051        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10052        std::fs::create_dir_all(path.join("target/package")).unwrap();
10053        std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
10054
10055        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10056            .expect("an ignored attribute file governs only ignored paths");
10057        assert!(
10058            !repo.has_uncommitted_changes(&path).unwrap(),
10059            "ignored dependency attributes must not stop the next review"
10060        );
10061    }
10062
10063    #[test]
10064    fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
10065        let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
10066        std::fs::create_dir_all(path.join("source")).unwrap();
10067        std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
10068
10069        assert!(repo.has_uncommitted_changes(&path).unwrap());
10070    }
10071
10072    #[test]
10073    fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
10074        for source in ["repository", "nested", "local", "global"] {
10075            let (_fixture, repo, path, _initial) = review_fixture(source, 945);
10076            std::fs::create_dir_all(path.join("manager")).unwrap();
10077            match source {
10078                "repository" => {
10079                    std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
10080                        .unwrap();
10081                    test_git(&path, &["add", ".gitignore"]);
10082                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
10083                }
10084                "nested" => {
10085                    std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
10086                    test_git(&path, &["add", "manager/.gitignore"]);
10087                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
10088                }
10089                "local" => exclude_paths(&repo, &["manager/scratch/"]),
10090                "global" => {
10091                    let excludes = repo.root().parent().unwrap().join("global-ignore");
10092                    std::fs::write(&excludes, "manager/scratch/\n").unwrap();
10093                    test_git(
10094                        &path,
10095                        &["config", "core.excludesFile", excludes.to_str().unwrap()],
10096                    );
10097                }
10098                _ => unreachable!(),
10099            }
10100            let output = path.join("manager/scratch");
10101            std::fs::create_dir_all(&output).unwrap();
10102            std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
10103            std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
10104            let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10105            std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
10106            std::fs::remove_file(output.join(".DS_Store")).unwrap();
10107            for file in [
10108                ".gitattributes",
10109                "Cargo.lock",
10110                "font.woff2",
10111                "bundle.js",
10112                "cache.db",
10113            ] {
10114                std::fs::write(output.join(file), "generated\n").unwrap();
10115            }
10116            #[cfg(unix)]
10117            std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
10118            let nested = output.join("dependency");
10119            std::fs::create_dir_all(&nested).unwrap();
10120            test_git(&nested, &["init"]);
10121            repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10122                .unwrap_or_else(|error| panic!("{source}: {error}"));
10123            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10124
10125            let baseline = repo.worktree_baseline(&path).unwrap();
10126            std::fs::write(path.join("README.md"), "source fix\n").unwrap();
10127            std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
10128            std::fs::remove_file(output.join("Cargo.lock")).unwrap();
10129            std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
10130            std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
10131            assert!(repo
10132                .commit_pending_changes(&path, &baseline, "fix source", "fix source")
10133                .unwrap_or_else(|error| panic!("{source}: {error}")));
10134            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
10135            assert!(test_git(
10136                &path,
10137                &[
10138                    "ls-tree",
10139                    "-r",
10140                    "--name-only",
10141                    "HEAD",
10142                    "--",
10143                    "manager/scratch"
10144                ]
10145            )
10146            .is_empty());
10147            assert!(
10148                repository_has_recoverable_work(&path, true).unwrap(),
10149                "{source}"
10150            );
10151            repo.release_review_worktree(945);
10152            assert_eq!(
10153                "LOCAL=updated\n",
10154                std::fs::read_to_string(output.join(".env.local")).unwrap()
10155            );
10156            assert!(nested.join(".git").exists(), "{source}");
10157        }
10158    }
10159
10160    /// Two reviewers share one checkout. The marker written when the first
10161    /// one's answer was refused used to be a brand new untracked file in the
10162    /// snapshot the second one was compared against.
10163    #[test]
10164    fn a_recovery_marker_is_not_a_change_to_the_worktree() {
10165        let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
10166        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10167        let _ = uncertain_worktree_change(&path, "an earlier call was refused");
10168
10169        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10170            .expect("spar's own marker is not something a call did");
10171    }
10172
10173    #[test]
10174    fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
10175        let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
10176
10177        let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
10178            .expect("a path a build removed is not an unreadable worktree");
10179
10180        assert_eq!(vanished_untracked_file(), gone);
10181    }
10182
10183    /// A path that goes is still a path that was there. Dropping it from the
10184    /// snapshot would let it pass for one the call never touched.
10185    #[test]
10186    fn a_vanished_path_is_not_recorded_as_absent() {
10187        let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
10188        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
10189        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10190        std::fs::remove_file(path.join("notes.txt")).unwrap();
10191
10192        let error = repo
10193            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10194            .unwrap_err();
10195
10196        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10197    }
10198
10199    fn exclude_paths(repo: &Repo, lines: &[&str]) {
10200        use std::io::Write;
10201        let path = repo.root().join(".git").join("info").join("exclude");
10202        let mut file = std::fs::OpenOptions::new()
10203            .create(true)
10204            .append(true)
10205            .open(&path)
10206            .unwrap();
10207        for line in lines {
10208            writeln!(file, "{line}").unwrap();
10209        }
10210    }
10211
10212    #[test]
10213    fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
10214        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
10215        exclude_paths(&repo, &["dist/"]);
10216        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10217        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10218        let baseline = repo.worktree_baseline(&path).unwrap();
10219        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
10220        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10221        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10222        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10223
10224        assert!(repo
10225            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10226            .unwrap());
10227        assert_eq!(
10228            "tracked change\n",
10229            std::fs::read_to_string(path.join("README.md")).unwrap()
10230        );
10231    }
10232
10233    #[test]
10234    fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
10235        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
10236        exclude_paths(&repo, &["dist/"]);
10237        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10238        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10239        std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
10240        let baseline = repo.worktree_baseline(&path).unwrap();
10241        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10242        std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
10243        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10244        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10245
10246        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10247        repo.refuse_changed_existing_untracked(&path, &baseline)
10248            .unwrap();
10249    }
10250
10251    #[test]
10252    fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
10253        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
10254        exclude_paths(&repo, &["dist/"]);
10255        std::fs::create_dir_all(path.join("dist")).unwrap();
10256        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10257        let baseline = repo.worktree_baseline(&path).unwrap();
10258        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10259        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10260
10261        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10262        repo.refuse_changed_existing_untracked(&path, &baseline)
10263            .unwrap();
10264    }
10265
10266    /// The run this comes from: a reviewer rebuilt a project whose build output
10267    /// lands in `manager/public/assets/`, which no directory list will ever
10268    /// name. The guard inside the call kept the review. This check then called
10269    /// the same thirteen files unrepresentable and ended the round, five
10270    /// minutes of review thrown away over a path Git had been told to disown.
10271    /// The run this comes from: `gh pr merge --delete-branch` could not delete
10272    /// `issue-784` because `.spar-worktrees/issue-784`, left by the run that
10273    /// opened the pull request, still had it checked out.
10274    #[test]
10275    fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
10276        let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
10277        let held = repo.root().join(WORKTREE_DIR).join("issue-950");
10278        std::fs::create_dir_all(held.parent().unwrap()).unwrap();
10279        test_git(
10280            repo.root(),
10281            &[
10282                "worktree",
10283                "add",
10284                "-b",
10285                "issue-950",
10286                held.to_str().unwrap(),
10287                "main",
10288            ],
10289        );
10290
10291        let found = repo
10292            .worktree_holding("issue-950")
10293            .expect("the branch is checked out somewhere");
10294
10295        assert!(found.ends_with("issue-950"), "{}", found.display());
10296        assert_eq!(None, repo.worktree_holding("issue-951"));
10297    }
10298
10299    #[test]
10300    fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
10301        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
10302        exclude_paths(&repo, &["manager/public/assets/"]);
10303        std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
10304        let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
10305        std::fs::write(&bundle, "first build\n").unwrap();
10306        let baseline = repo.worktree_baseline(&path).unwrap();
10307        std::fs::write(&bundle, "second build\n").unwrap();
10308        std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
10309
10310        repo.refuse_read_only_leavings(&path, &baseline).unwrap();
10311        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10312    }
10313
10314    #[test]
10315    fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
10316        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
10317        exclude_paths(&repo, &["dist/"]);
10318        let baseline = repo.worktree_baseline(&path).unwrap();
10319        std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
10320
10321        let error = repo
10322            .refuse_read_only_leavings(&path, &baseline)
10323            .unwrap_err();
10324
10325        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10326        assert!(error.to_string().contains("scratch.md"), "{error}");
10327    }
10328
10329    #[test]
10330    fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
10331        let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
10332        exclude_paths(&repo, &["dist/", ".env.local"]);
10333        std::fs::create_dir_all(path.join("dist")).unwrap();
10334        let baseline = repo.worktree_baseline(&path).unwrap();
10335        std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
10336        std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
10337
10338        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10339        assert!(repository_has_recoverable_work(&path, true).unwrap());
10340    }
10341
10342    #[test]
10343    fn a_read_only_inspection_may_rebuild_generated_output() {
10344        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
10345        exclude_paths(&repo, &["dist/"]);
10346        std::fs::create_dir_all(path.join("dist")).unwrap();
10347        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10348        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10349        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10350        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10351
10352        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10353            .unwrap();
10354    }
10355
10356    #[test]
10357    fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
10358        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
10359        exclude_paths(&repo, &["dist/", ".env.local"]);
10360        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10361        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10362        std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
10363
10364        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10365            .unwrap();
10366    }
10367
10368    #[test]
10369    fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
10370        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
10371        exclude_paths(&repo, &["dist/", ".env.local"]);
10372        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10373        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10374        std::fs::remove_file(path.join(".env.local")).unwrap();
10375
10376        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10377            .unwrap();
10378    }
10379
10380    #[test]
10381    fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
10382        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
10383        exclude_paths(&repo, &["dist/"]);
10384        std::fs::write(path.join("notes.md"), "before\n").unwrap();
10385        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10386        std::fs::write(path.join("notes.md"), "after\n").unwrap();
10387
10388        let error = repo
10389            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10390            .unwrap_err();
10391
10392        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10393        assert!(error.to_string().contains("notes.md"), "{error}");
10394    }
10395
10396    #[test]
10397    fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
10398        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
10399        exclude_paths(&repo, &["dist/", "local.env"]);
10400        std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
10401        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10402        // Stop ignoring it, so the same bytes are now an ordinary untracked
10403        // file. What a path is counts, not only whether it moved.
10404        std::fs::write(
10405            repo.root().join(".git").join("info").join("exclude"),
10406            "dist/\n",
10407        )
10408        .unwrap();
10409
10410        let error = repo
10411            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10412            .unwrap_err();
10413
10414        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10415        assert!(error.to_string().contains("local.env"), "{error}");
10416    }
10417
10418    #[test]
10419    fn a_read_only_inspection_names_the_tracked_file_that_moved() {
10420        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
10421        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10422        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
10423
10424        let error = repo
10425            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10426            .unwrap_err();
10427
10428        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10429        assert!(error.to_string().contains("README.md"), "{error}");
10430    }
10431
10432    #[test]
10433    fn build_output_alone_does_not_keep_a_worktree() {
10434        let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
10435        exclude_paths(&repo, &["target/", "dist/"]);
10436        std::fs::create_dir_all(path.join("target/debug")).unwrap();
10437        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10438        std::fs::create_dir_all(path.join("dist/cli")).unwrap();
10439        std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
10440
10441        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10442        repo.release_review_worktree(933);
10443
10444        assert!(!path.exists());
10445    }
10446
10447    #[test]
10448    fn finder_metadata_alone_does_not_keep_a_worktree() {
10449        let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
10450        exclude_paths(&repo, &[".DS_Store"]);
10451        std::fs::create_dir_all(path.join("src")).unwrap();
10452        std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
10453        std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
10454
10455        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10456        repo.release_review_worktree(937);
10457
10458        assert!(!path.exists());
10459    }
10460
10461    #[test]
10462    fn a_fetch_record_alone_does_not_keep_a_worktree() {
10463        let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
10464        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
10465        let git_dir = if raw.is_absolute() {
10466            raw
10467        } else {
10468            path.join(raw)
10469        };
10470        let head = test_git(&path, &["rev-parse", "HEAD"]);
10471        std::fs::write(
10472            git_dir.join("FETCH_HEAD"),
10473            format!("{}\t\tbranch 'main' of origin\n", head.trim()),
10474        )
10475        .unwrap();
10476
10477        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10478        repo.release_review_worktree(938);
10479
10480        assert!(!path.exists());
10481    }
10482
10483    #[test]
10484    fn an_ignored_file_outside_build_output_keeps_a_worktree() {
10485        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
10486        exclude_paths(&repo, &["target/", ".env.local"]);
10487        std::fs::create_dir_all(path.join("target/debug")).unwrap();
10488        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10489        std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
10490
10491        assert!(repository_has_recoverable_work(&path, true).unwrap());
10492        repo.release_review_worktree(934);
10493
10494        assert_eq!(
10495            "TOKEN=keep me\n",
10496            std::fs::read_to_string(path.join(".env.local")).unwrap()
10497        );
10498    }
10499
10500    #[test]
10501    fn a_repository_nested_in_build_output_keeps_a_worktree() {
10502        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
10503        exclude_paths(&repo, &["node_modules/"]);
10504        let nested = path.join("node_modules/local-dep");
10505        std::fs::create_dir_all(&nested).unwrap();
10506        test_git(&nested, &["init"]);
10507        std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
10508
10509        assert!(repository_has_recoverable_work(&path, true).unwrap());
10510        repo.release_review_worktree(935);
10511
10512        assert!(nested.join(".git").exists());
10513    }
10514
10515    #[test]
10516    fn an_ordinary_untracked_file_keeps_a_worktree() {
10517        let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
10518        std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
10519
10520        assert!(repository_has_recoverable_work(&path, true).unwrap());
10521        repo.release_review_worktree(936);
10522
10523        assert_eq!(
10524            "somebody's notes\n",
10525            std::fs::read_to_string(path.join("notes.md")).unwrap()
10526        );
10527    }
10528
10529    #[test]
10530    fn a_legacy_crlf_checkout_is_retained_conservatively() {
10531        let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
10532        std::fs::write(
10533            path.join(".gitattributes"),
10534            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
10535        )
10536        .unwrap();
10537        test_git(&path, &["add", ".gitattributes"]);
10538        test_git(&path, &["commit", "-m", "select legacy line endings"]);
10539
10540        assert!(repository_has_recoverable_work(&path, true).unwrap());
10541    }
10542
10543    #[test]
10544    fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
10545        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
10546        let nested = path.join("tracked");
10547        std::fs::create_dir_all(&nested).unwrap();
10548        std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
10549        test_git(&path, &["add", "tracked/seed.txt"]);
10550        test_git(&path, &["commit", "-m", "add tracked directory"]);
10551        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10552        test_git(&nested, &["init"]);
10553
10554        let error = repo
10555            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10556            .unwrap_err();
10557
10558        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10559        assert!(error.to_string().contains("Git entry"), "{error}");
10560        assert!(nested.join(".git").exists());
10561    }
10562
10563    /// A reviewer that clones a reproduction into ignored build output used to
10564    /// lose the whole review. The state check refused every Git entry it had
10565    /// not put there, including the one Git reports as a nested checkout and
10566    /// the untracked snapshot already records by identity.
10567    #[test]
10568    fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
10569        let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
10570        exclude_paths(&repo, &["target/"]);
10571        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10572        let repro = path.join("target/pr918-repro");
10573        std::fs::create_dir_all(&repro).unwrap();
10574        test_git(&repro, &["init"]);
10575        std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
10576
10577        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10578            .unwrap();
10579
10580        assert!(repository_has_recoverable_work(&path, true).unwrap());
10581        assert!(repro.join(".git").exists());
10582    }
10583
10584    /// What sits under a nested checkout is that repository's business, so the
10585    /// scan stops at its directory rather than reading a checkout of its own
10586    /// as an entry the outer worktree has to account for.
10587    #[test]
10588    fn a_repository_inside_a_nested_checkout_is_left_alone() {
10589        let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
10590        exclude_paths(&repo, &["target/"]);
10591        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10592        let repro = path.join("target/repro");
10593        let vendored = repro.join("vendor/dep");
10594        std::fs::create_dir_all(&vendored).unwrap();
10595        test_git(&repro, &["init"]);
10596        test_git(&vendored, &["init"]);
10597
10598        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10599            .unwrap();
10600
10601        assert!(vendored.join(".git").exists());
10602    }
10603
10604    #[test]
10605    fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
10606        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
10607
10608        let state = ignored_untracked_state(repo.root()).unwrap();
10609
10610        let relative = path.strip_prefix(repo.root()).unwrap();
10611        assert!(
10612            state.files.contains_key(relative),
10613            "{:?}",
10614            state.files.keys().collect::<Vec<_>>()
10615        );
10616        assert!(state.is_ignored(relative));
10617    }
10618
10619    #[test]
10620    fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
10621        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
10622        let baseline = repo.worktree_baseline(repo.root()).unwrap();
10623        std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
10624        std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
10625
10626        repo.refuse_new_ignored_files(repo.root(), &baseline)
10627            .unwrap();
10628        repo.refuse_changed_existing_untracked(repo.root(), &baseline)
10629            .unwrap();
10630    }
10631
10632    #[test]
10633    fn deleting_a_resident_worktree_during_a_call_is_refused() {
10634        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
10635        let baseline = repo.worktree_baseline(repo.root()).unwrap();
10636        let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
10637        std::fs::remove_dir_all(&path).unwrap();
10638
10639        let read_error = repo
10640            .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
10641            .unwrap_err();
10642        assert!(
10643            read_error.to_string().contains("review-932"),
10644            "{read_error}"
10645        );
10646        let error = repo
10647            .refuse_new_ignored_files(repo.root(), &baseline)
10648            .unwrap_err();
10649
10650        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10651        assert!(error.to_string().contains("review-932"), "{error}");
10652    }
10653
10654    #[test]
10655    fn a_nested_repository_record_is_read_as_a_plain_path() {
10656        let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
10657        assert_eq!(Path::new("vendor/checkout"), path);
10658        assert!(nested);
10659
10660        let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
10661        assert_eq!(Path::new("vendor/notes.txt"), path);
10662        assert!(!nested);
10663
10664        assert!(untracked_record(b"/", "untracked").is_err());
10665    }
10666
10667    #[cfg(unix)]
10668    #[test]
10669    fn a_non_utf8_git_path_is_preserved_without_loss() {
10670        use std::os::unix::ffi::OsStrExt;
10671
10672        let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
10673
10674        assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
10675    }
10676
10677    #[test]
10678    fn guarded_merge_pins_the_reviewed_head() {
10679        let args = merge_pr_args("36", Some("abc123"), true);
10680        assert_eq!(
10681            vec![
10682                "pr",
10683                "merge",
10684                "36",
10685                "--squash",
10686                "--delete-branch",
10687                "--match-head-commit",
10688                "abc123"
10689            ],
10690            args
10691        );
10692    }
10693
10694    #[test]
10695    fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
10696        let pr = PrRef {
10697            number: 7,
10698            url: "https://example.test/pull/7".into(),
10699            title: "part one".into(),
10700        };
10701        let result = reconcile_pr_creation(
10702            "split-34-1",
10703            Err(crate::error::SparError::new("connection lost")),
10704            Ok(Some(pr)),
10705        )
10706        .unwrap();
10707        assert_eq!(7, result.number);
10708    }
10709
10710    #[test]
10711    fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
10712        let error = reconcile_pr_creation(
10713            "split-34-1",
10714            Err(crate::error::SparError::new("permission denied")),
10715            Ok(None),
10716        )
10717        .unwrap_err();
10718        assert!(error.to_string().contains("permission denied"), "{error}");
10719    }
10720
10721    #[test]
10722    fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
10723        let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
10724        assert!(pr_for_base(text, "split-34-2", "split-34-1")
10725            .unwrap()
10726            .is_none());
10727        let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
10728        assert_eq!(7, found.number);
10729    }
10730
10731    #[test]
10732    fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
10733        let result = reconcile_comment_post(
10734            34,
10735            "the summary",
10736            crate::error::SparError::new("connection lost"),
10737            Ok(vec![serde_json::json!({"body": "the summary"})]),
10738        );
10739        assert!(result.is_ok(), "{result:?}");
10740    }
10741
10742    #[test]
10743    fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
10744        let error = reconcile_comment_post(
10745            34,
10746            "the summary",
10747            crate::error::SparError::new("connection lost"),
10748            Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
10749        )
10750        .unwrap_err();
10751        assert_eq!("connection lost", error.to_string());
10752    }
10753
10754    #[test]
10755    fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
10756        let error = reconcile_comment_post(
10757            34,
10758            "the summary",
10759            crate::error::SparError::new("connection lost"),
10760            Err(crate::error::SparError::new("comments unavailable")),
10761        )
10762        .unwrap_err();
10763        assert!(
10764            error.to_string().contains("could not be verified"),
10765            "{error}"
10766        );
10767        assert!(
10768            error.to_string().contains("comments unavailable"),
10769            "{error}"
10770        );
10771        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10772        assert!(!error.worth_retrying());
10773    }
10774
10775    #[test]
10776    fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
10777        let result = reconcile_issue_edit(
10778            34,
10779            "wanted body",
10780            crate::error::SparError::new("connection lost"),
10781            Ok("wanted body".to_string()),
10782        );
10783        assert!(result.is_ok(), "{result:?}");
10784    }
10785
10786    #[test]
10787    fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
10788        let error = reconcile_issue_edit(
10789            34,
10790            "wanted body",
10791            crate::error::SparError::new("connection lost"),
10792            Err(crate::error::SparError::new("issue unavailable")),
10793        )
10794        .unwrap_err();
10795        assert!(
10796            error.to_string().contains("could not be verified"),
10797            "{error}"
10798        );
10799        assert!(error.to_string().contains("issue unavailable"), "{error}");
10800        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10801        assert!(!error.worth_retrying());
10802    }
10803
10804    #[test]
10805    fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
10806        let found = ExistingIssue {
10807            number: 101,
10808            url: "https://example.test/issues/101".into(),
10809            title: "child".into(),
10810            body: "body".into(),
10811            open: true,
10812        };
10813        let url = reconcile_issue_creation(
10814            "child",
10815            Err(crate::error::SparError::new("connection lost")),
10816            Ok(Some(found)),
10817        )
10818        .unwrap();
10819        assert_eq!("https://example.test/issues/101", url);
10820    }
10821
10822    #[test]
10823    fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
10824        let error = reconcile_issue_creation(
10825            "child",
10826            Err(crate::error::SparError::new("permission denied")),
10827            Ok(None),
10828        )
10829        .unwrap_err();
10830        assert!(error.to_string().contains("permission denied"), "{error}");
10831    }
10832
10833    #[test]
10834    fn an_unverifiable_issue_creation_is_marked_uncertain() {
10835        let error = reconcile_issue_creation(
10836            "child",
10837            Err(crate::error::SparError::new("connection lost")),
10838            Err(crate::error::SparError::new("issues unavailable")),
10839        )
10840        .unwrap_err();
10841        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10842        assert!(!error.worth_retrying());
10843    }
10844
10845    #[test]
10846    fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
10847        let result = reconcile_failed_split_push(
10848            "split-34-1",
10849            crate::error::SparError::new("connection lost"),
10850            Ok("abc123\n".into()),
10851            Ok("abc123\trefs/heads/split-34-1\n".into()),
10852        );
10853        assert!(result.is_ok(), "{result:?}");
10854    }
10855
10856    #[test]
10857    fn a_split_push_collision_is_definite_and_never_overwrites() {
10858        let error = reconcile_failed_split_push(
10859            "split-34-1",
10860            crate::error::SparError::new("lease rejected"),
10861            Ok("abc123\n".into()),
10862            Ok("def456\trefs/heads/split-34-1\n".into()),
10863        )
10864        .unwrap_err();
10865        assert!(!error.retain_worktree());
10866        assert!(
10867            error.to_string().contains("Nothing was overwritten"),
10868            "{error}"
10869        );
10870    }
10871
10872    #[test]
10873    fn an_unreadable_split_push_result_keeps_the_worktree() {
10874        let error = reconcile_failed_split_push(
10875            "split-34-1",
10876            crate::error::SparError::new("connection lost"),
10877            Ok("abc123\n".into()),
10878            Err(crate::error::SparError::new("origin unavailable")),
10879        )
10880        .unwrap_err();
10881        assert!(error.retain_worktree());
10882        assert!(error.to_string().contains("could not confirm"), "{error}");
10883    }
10884
10885    /// Follow-up deduplication compares a title it computed against the title
10886    /// GitHub stored. If those two transforms can disagree, the check never
10887    /// matches and every review round files another copy of the same issue.
10888    #[test]
10889    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
10890        let repo = repo_for_titles();
10891        for raw in [
10892            "Retry loop spins \u{2014} Retry-After parses to zero",
10893            "plain title",
10894            "  spread   over\nlines  ",
10895            "\u{1F916} Generated with something",
10896            &format!("a \u{2014} {}", "very long title ".repeat(20)),
10897            &"x".repeat(300),
10898            &format!("{} \u{2014} end", "y".repeat(88)),
10899            // Exactly the budget, with two spaceless dashes. The scrub turns
10900            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
10901            // character over budget per dash and a second pass clips again,
10902            // producing a different string. Scrub-then-clip cannot.
10903            &{
10904                let tail = "a\u{2014}b c\u{2014}d";
10905                let pad = Style::default().max_title_chars - tail.chars().count();
10906                format!("{}{tail}", "w".repeat(pad))
10907            },
10908        ] {
10909            let once = repo.clean_title(raw).unwrap();
10910            let twice = repo.clean_title(&once).unwrap();
10911            assert_eq!(once, twice, "not idempotent for {raw:?}");
10912            assert!(
10913                once.chars().count() <= repo.style.max_title_chars,
10914                "over budget: {once:?}"
10915            );
10916            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
10917        }
10918    }
10919
10920    #[test]
10921    fn a_title_with_an_em_dash_survives_as_readable_text() {
10922        let repo = repo_for_titles();
10923        assert_eq!(
10924            "Retry loop spins, Retry-After parses to zero",
10925            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
10926                .unwrap()
10927        );
10928    }
10929
10930    #[test]
10931    fn sh_quote_survives_a_quote() {
10932        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
10933    }
10934
10935    #[test]
10936    fn sh_quote_wraps_a_space() {
10937        assert_eq!(
10938            "'/Applications/My App/spar'",
10939            sh_quote("/Applications/My App/spar")
10940        );
10941    }
10942
10943    #[test]
10944    fn finished_states_are_recognised_case_insensitively() {
10945        assert!(is_finished("MERGED"));
10946        assert!(is_finished("closed"));
10947        assert!(!is_finished("OPEN"));
10948        assert!(!is_finished(""));
10949    }
10950
10951    fn state() -> PersistedState {
10952        PersistedState {
10953            version: 1,
10954            checkpoint: 0,
10955            round: 4,
10956            next_actor: "codex".into(),
10957            status: Status::Pending,
10958            pr_head: "abc123".into(),
10959            ledger: Ledger::new(),
10960            filed: vec![],
10961            open_findings: vec![Finding {
10962                severity: Severity::Blocking,
10963                title: "Unchecked error".into(),
10964                detail: "the failure is discarded".into(),
10965                file: "src/a.rs:12".into(),
10966                ..Finding::default()
10967            }],
10968            disputes: vec![Dispute {
10969                title: "Retry limit".into(),
10970                file: "src/net.rs".into(),
10971                reasoning: "the caller already bounds it".into(),
10972            }],
10973            noted: vec![Finding {
10974                severity: Severity::NonBlocking,
10975                title: "Timeout is fixed".into(),
10976                file: "src/config.rs".into(),
10977                ..Finding::default()
10978            }],
10979        }
10980    }
10981
10982    #[test]
10983    fn a_state_comment_round_trips() {
10984        let body = format!(
10985            "{STATE_MARKER}\n{}\n-->",
10986            serde_json::to_string(&state()).unwrap()
10987        );
10988        let back = parse_state_comment(&body).unwrap();
10989        assert_eq!(4, back.round);
10990        assert_eq!("codex", back.next_actor);
10991        assert_eq!("abc123", back.pr_head);
10992        assert_eq!("Unchecked error", back.open_findings[0].title);
10993        assert_eq!("src/net.rs", back.disputes[0].file);
10994        assert_eq!("Timeout is fixed", back.noted[0].title);
10995    }
10996
10997    #[test]
10998    fn old_state_without_new_lists_still_parses() {
10999        let body = format!(
11000            "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
11001             \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11002        );
11003        let back = parse_state_comment(&body).expect("old state");
11004        assert!(back.open_findings.is_empty());
11005        assert!(back.disputes.is_empty());
11006        assert!(back.noted.is_empty());
11007        assert!(back.pr_head.is_empty());
11008        assert_eq!(0, back.checkpoint);
11009    }
11010
11011    #[test]
11012    fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
11013        let mut local = state();
11014        local.pr_head = "old".into();
11015        local.round = 9;
11016        let mut remote = state();
11017        remote.pr_head = "current".into();
11018        remote.round = 4;
11019
11020        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11021        assert_eq!("current", chosen.pr_head);
11022        assert_eq!(4, chosen.round);
11023    }
11024
11025    #[test]
11026    fn checkpoint_order_breaks_same_round_ties() {
11027        let mut local = state();
11028        local.pr_head = "current".into();
11029        local.round = 4;
11030        local.checkpoint = 8;
11031        let mut remote = local.clone();
11032        remote.checkpoint = 7;
11033        remote.open_findings.clear();
11034
11035        let chosen = choose_state_for_head(vec![local], "current").unwrap();
11036        assert_eq!(8, chosen.checkpoint);
11037
11038        let mut local = state();
11039        local.pr_head = "current".into();
11040        local.round = 4;
11041        local.checkpoint = 8;
11042        let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
11043        assert_eq!(8, chosen.checkpoint);
11044    }
11045
11046    #[test]
11047    fn legacy_same_round_tie_keeps_the_local_checkpoint() {
11048        let mut local = state();
11049        local.pr_head = "current".into();
11050        local.round = 4;
11051        local.open_findings.push(Finding {
11052            title: "local checkpoint".into(),
11053            ..Finding::default()
11054        });
11055        let mut remote = state();
11056        remote.pr_head = "current".into();
11057        remote.round = 4;
11058
11059        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
11060        assert_eq!(
11061            "local checkpoint",
11062            chosen.open_findings.last().unwrap().title
11063        );
11064    }
11065
11066    /// It must render as nothing, so PRs are not littered with machine state.
11067    #[test]
11068    fn the_state_block_is_an_html_comment() {
11069        let body = format!(
11070            "{STATE_MARKER}\n{}\n-->",
11071            serde_json::to_string(&state()).unwrap()
11072        );
11073        assert!(body.starts_with("<!--"));
11074        assert!(body.trim_end().ends_with("-->"));
11075        assert!(!body[..body.find('{').unwrap()].contains("-->"));
11076    }
11077
11078    #[test]
11079    fn an_unrelated_json_block_is_not_state() {
11080        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
11081    }
11082
11083    /// Patching and deleting a comment is not something to do on a substring.
11084    ///
11085    /// A maintainer who notices the block and asks about it writes a comment
11086    /// containing `spar:state`, and the loose filter could not tell theirs from
11087    /// spar's. The next `persist` replaced their words with JSON and
11088    /// `spar clean --pr-state` later deleted it.
11089    #[test]
11090    fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
11091        let block = format!(
11092            "{STATE_MARKER}\n{}\n-->",
11093            serde_json::to_string(&state()).unwrap()
11094        );
11095        assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
11096        assert!(
11097            is_state_comment(&block, "Spar-Bot", "spar-bot"),
11098            "a login differs only in case"
11099        );
11100
11101        assert!(
11102            !is_state_comment(
11103                "why is there a `<!-- spar:state` block on this PR?",
11104                "maintainer",
11105                "spar-bot"
11106            ),
11107            "a question about the block was treated as the block"
11108        );
11109        assert!(
11110            !is_state_comment(&block, "maintainer", "spar-bot"),
11111            "somebody else's comment is not spar's to rewrite"
11112        );
11113        assert!(
11114            !is_state_comment(
11115                &format!("here is what spar writes:\n\n{block}"),
11116                "spar-bot",
11117                "spar-bot"
11118            ),
11119            "the marker has to open the body, not appear in it"
11120        );
11121        assert!(
11122            !is_state_comment(
11123                &format!("{STATE_MARKER}\n{{not json\n-->"),
11124                "spar-bot",
11125                "spar-bot"
11126            ),
11127            "a block that does not parse is not state"
11128        );
11129        assert!(
11130            !is_state_comment(&block, "spar-bot", ""),
11131            "an unknown viewer proves nothing about who wrote it"
11132        );
11133    }
11134
11135    #[test]
11136    fn a_malformed_state_comment_is_none_not_a_panic() {
11137        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
11138    }
11139
11140    #[test]
11141    fn atomic_write_leaves_no_temp_file() {
11142        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
11143        let _ = std::fs::remove_dir_all(&dir);
11144        let path = dir.join("state").join("pr-7.json");
11145        write_json_atomic(&path, &state()).unwrap();
11146        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
11147            .unwrap()
11148            .flatten()
11149            .filter_map(|e| e.file_name().to_str().map(str::to_string))
11150            .collect();
11151        assert_eq!(vec!["pr-7.json".to_string()], files);
11152        let _ = std::fs::remove_dir_all(&dir);
11153    }
11154
11155    #[test]
11156    fn atomic_write_overwrites_rather_than_accumulating() {
11157        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
11158        let _ = std::fs::remove_dir_all(&dir);
11159        let path = dir.join("pr-7.json");
11160        for round in 1..4 {
11161            let mut s = state();
11162            s.round = round;
11163            write_json_atomic(&path, &s).unwrap();
11164        }
11165        let back: PersistedState =
11166            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11167        assert_eq!(3, back.round);
11168        let _ = std::fs::remove_dir_all(&dir);
11169    }
11170
11171    #[test]
11172    fn style_from_env_defaults_to_enforcing() {
11173        std::env::remove_var("SPAR_BAN_EM_DASH");
11174        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
11175        let style = style_from_env();
11176        assert!(style.ban_em_dash && style.ban_ai_attribution);
11177        assert!(
11178            !style.terse,
11179            "the commit filter must not truncate a commit message"
11180        );
11181    }
11182
11183    // -- publishing from a fork --------------------------------------------
11184
11185    /// GitHub's refusal is several lines of advice after the one that says
11186    /// what happened, and spar used to report only the last of them.
11187    #[test]
11188    fn a_push_denial_is_read_from_either_transport() {
11189        let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
11190                   fatal: Could not read from remote repository.\n\n\
11191                   Please make sure you have the correct access rights\n\
11192                   and the repository exists.";
11193        let https = "remote: Permission to example/project.git denied to someone.\n\
11194                     fatal: unable to access 'https://github.com/example/project.git/': The \
11195                     requested URL returned error: 403";
11196        let denied = Some("Permission to example/project.git denied to someone.");
11197        assert_eq!(denied, push_denial(ssh));
11198        assert_eq!(denied, push_denial(https));
11199
11200        let stale = "To github.com:example/project.git\n \
11201                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
11202                     error: failed to push some refs to 'github.com:example/project.git'";
11203        assert_eq!(None, push_denial(stale));
11204
11205        let err = push_error(
11206            &PushTarget::Origin,
11207            "issue-7",
11208            &crate::error::SparError::new(ssh),
11209        );
11210        assert_eq!(
11211            "could not push to origin/issue-7. Permission to example/project.git denied to \
11212             someone.",
11213            err.to_string()
11214        );
11215    }
11216
11217    /// An origin whose hook refuses every push with GitHub's wording, a bare
11218    /// repository standing in for the fork, and a checkout on a new branch
11219    /// with one commit to publish.
11220    #[cfg(unix)]
11221    fn denied_fixture(
11222        tag: &str,
11223        fork_when_denied: bool,
11224    ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
11225        use std::os::unix::fs::PermissionsExt;
11226        use std::sync::atomic::{AtomicU32, Ordering};
11227        static NEXT: AtomicU32 = AtomicU32::new(0);
11228        let id = NEXT.fetch_add(1, Ordering::Relaxed);
11229        let root =
11230            std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
11231        let origin = root.join("origin.git");
11232        let fork = root.join("fork.git");
11233        let work = root.join("work");
11234        for dir in [&origin, &fork, &work] {
11235            std::fs::create_dir_all(dir).unwrap();
11236        }
11237        test_git(&origin, &["init", "--bare", "-b", "main"]);
11238        test_git(&fork, &["init", "--bare", "-b", "main"]);
11239        test_git(&work, &["init", "-b", "main"]);
11240        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
11241        test_git(&work, &["config", "user.name", "spar test"]);
11242        test_git(&work, &["config", "commit.gpgsign", "false"]);
11243        std::fs::write(work.join("README.md"), "seed\n").unwrap();
11244        test_git(&work, &["add", "."]);
11245        test_git(&work, &["commit", "-m", "seed"]);
11246        test_git(
11247            &work,
11248            &["remote", "add", "origin", origin.to_str().unwrap()],
11249        );
11250        test_git(&work, &["push", "-q", "origin", "main"]);
11251        // Installed after the seed, so it is only spar's pushes it refuses.
11252        let hook = origin.join("hooks").join("pre-receive");
11253        std::fs::write(
11254            &hook,
11255            "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
11256        )
11257        .unwrap();
11258        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
11259        test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
11260        std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
11261        test_git(&work, &["add", "."]);
11262        test_git(&work, &["commit", "-m", "Fix it"]);
11263
11264        let mut text =
11265            String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
11266        if !fork_when_denied {
11267            text.push_str("[loop]\nfork_when_denied = false\n");
11268        }
11269        let cfg = crate::config::parse(&text).unwrap();
11270        let repo = Repo::open(&work, &cfg).unwrap();
11271        // What `fork` would have asked GitHub for, so no test reaches it.
11272        repo.fork
11273            .set(Fork {
11274                owner: "someone".into(),
11275                full_name: "someone/project".into(),
11276                url: fork.to_str().unwrap().into(),
11277            })
11278            .unwrap();
11279        (ReviewFixture { root }, repo, work, origin, fork)
11280    }
11281
11282    #[cfg(unix)]
11283    #[test]
11284    fn a_new_branch_origin_refuses_is_published_from_the_fork() {
11285        let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
11286        let first = test_git(&work, &["rev-parse", "HEAD"]);
11287
11288        let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11289
11290        assert_eq!("someone:issue-7", target.head("issue-7"));
11291        assert_eq!(
11292            first.trim(),
11293            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11294        );
11295        assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11296            .trim()
11297            .is_empty());
11298        assert_eq!(
11299            WriteSummary {
11300                attempted: 1,
11301                failed: 0
11302            },
11303            repo.write_summary(),
11304            "a refusal the fork answered is not a failed write"
11305        );
11306
11307        // A later round follows the branch to the fork, leasing what it
11308        // published there.
11309        std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
11310        test_git(&work, &["commit", "-qam", "Fix it again"]);
11311        repo.push_to(&work, &target, "issue-7", Some(first.trim()))
11312            .unwrap();
11313        let second = test_git(&work, &["rev-parse", "HEAD"]);
11314        assert_eq!(
11315            second.trim(),
11316            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11317        );
11318    }
11319
11320    /// What a closed pull request leaves on the fork: a branch with nothing
11321    /// a pull request does not hold. Origin's issue branches are rebuilt over
11322    /// in the same case.
11323    #[cfg(unix)]
11324    #[test]
11325    fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
11326        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
11327        test_git(
11328            &work,
11329            &[
11330                "push",
11331                "-q",
11332                fork.to_str().unwrap(),
11333                "main:refs/heads/issue-7",
11334            ],
11335        );
11336        let head = test_git(&work, &["rev-parse", "HEAD"]);
11337
11338        repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11339
11340        assert_eq!(
11341            head.trim(),
11342            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11343        );
11344        assert_eq!(0, repo.write_summary().failed);
11345    }
11346
11347    #[cfg(unix)]
11348    #[test]
11349    fn work_only_the_fork_has_is_not_published_over() {
11350        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
11351        test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
11352        std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
11353        test_git(&work, &["add", "."]);
11354        test_git(&work, &["commit", "-qm", "Somebody's work"]);
11355        test_git(
11356            &work,
11357            &[
11358                "push",
11359                "-q",
11360                fork.to_str().unwrap(),
11361                "elsewhere:refs/heads/issue-7",
11362            ],
11363        );
11364        let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
11365        test_git(&work, &["checkout", "-q", "issue-7"]);
11366
11367        let err = repo
11368            .publish_new_branch(&work, "issue-7", "main")
11369            .unwrap_err()
11370            .to_string();
11371
11372        assert!(
11373            err.contains(
11374                "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
11375            ),
11376            "{err}"
11377        );
11378        assert!(err.contains("--delete issue-7"), "{err}");
11379        assert_eq!(
11380            theirs.trim(),
11381            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11382        );
11383        assert_eq!(
11384            WriteSummary {
11385                attempted: 1,
11386                failed: 1
11387            },
11388            repo.write_summary()
11389        );
11390    }
11391
11392    /// A lease that loses says which ref and why, not git's closing line.
11393    #[test]
11394    fn a_rejected_push_names_the_ref_it_rejected() {
11395        let stale = "To github.com:example/project.git\n \
11396                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
11397                     error: failed to push some refs to 'github.com:example/project.git'";
11398
11399        let err = push_error(
11400            &PushTarget::Origin,
11401            "issue-7",
11402            &crate::error::SparError::new(stale),
11403        )
11404        .to_string();
11405
11406        assert!(
11407            err.starts_with(
11408                "could not push to origin/issue-7. [rejected]        HEAD -> issue-7 (stale info)"
11409            ),
11410            "{err}"
11411        );
11412    }
11413
11414    #[test]
11415    fn a_fork_is_reached_by_whatever_reaches_origin() {
11416        let cases = [
11417            (
11418                "git@github.com:example/project.git",
11419                Some("git@github.com:someone/project.git"),
11420            ),
11421            // No user: ~/.ssh/config supplies it.
11422            (
11423                "github.com:example/project.git",
11424                Some("github.com:someone/project.git"),
11425            ),
11426            // A host alias, how one machine pushes as two accounts.
11427            (
11428                "git@github-work:example/project.git",
11429                Some("git@github-work:someone/project.git"),
11430            ),
11431            // SSH over 443, where 22 is blocked.
11432            (
11433                "ssh://git@ssh.github.com:443/example/project.git",
11434                Some("ssh://git@ssh.github.com:443/someone/project.git"),
11435            ),
11436            (
11437                "https://github.com/example/project",
11438                Some("https://github.com/someone/project"),
11439            ),
11440            (
11441                "https://ghe.example.com/scm/example/project.git/",
11442                Some("https://ghe.example.com/scm/someone/project.git"),
11443            ),
11444            (
11445                "git@host:/srv/example/project.git",
11446                Some("git@host:/srv/someone/project.git"),
11447            ),
11448            ("/srv/git/project.git", None),
11449            ("https://github.com/project", None),
11450            ("", None),
11451        ];
11452        for (origin, want) in cases {
11453            assert_eq!(
11454                want.map(str::to_string),
11455                fork_url(origin, "someone/project"),
11456                "{origin}"
11457            );
11458        }
11459    }
11460
11461    #[test]
11462    fn a_fork_found_or_made_is_reached_the_way_origin_is() {
11463        let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
11464            "ssh_url":"git@github.com:someone/project.git",
11465            "clone_url":"https://github.com/someone/project.git","fork":true}"#;
11466        let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
11467            "sshUrl":"git@github.com:someone/project.git",
11468            "url":"https://github.com/someone/project"}]"#;
11469
11470        let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
11471        assert_eq!("git@github-work:someone/project.git", fork.url);
11472        assert_eq!("someone", fork.owner);
11473        assert_eq!("someone/project", fork.full_name);
11474        let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
11475            .unwrap()
11476            .unwrap();
11477        assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
11478        assert_eq!(
11479            None,
11480            fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
11481        );
11482
11483        // An origin with no owner/repo path falls back to GitHub's URL, the
11484        // SSH one when origin looks like SSH.
11485        let fork = fork_from_api(made, "https://github.com/project").unwrap();
11486        assert_eq!("https://github.com/someone/project.git", fork.url);
11487        let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
11488        assert_eq!("git@github.com:someone/project.git", fork.url);
11489
11490        assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
11491        assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
11492    }
11493
11494    #[test]
11495    fn only_the_viewers_own_fork_can_be_pushed_to() {
11496        let pr = |owner: &str, name: &str| PrView {
11497            number: 9,
11498            url: String::new(),
11499            title: String::new(),
11500            body: String::new(),
11501            head_ref_name: "issue-7".into(),
11502            base_ref_name: "main".into(),
11503            state: "OPEN".into(),
11504            closing_issues_references: Vec::new(),
11505            is_cross_repository: true,
11506            head_repository_owner: Some(crate::model::Author {
11507                login: owner.into(),
11508            }),
11509            head_repository: Some(crate::model::HeadRepository { name: name.into() }),
11510        };
11511        let origin = "git@github.com:example/project.git";
11512
11513        assert_eq!(
11514            Some(PushTarget::Fork(Fork {
11515                owner: "Someone".into(),
11516                full_name: "Someone/project".into(),
11517                url: "git@github.com:Someone/project.git".into(),
11518            })),
11519            own_fork_target(&pr("Someone", "project"), "someone", origin)
11520        );
11521        assert_eq!(
11522            None,
11523            own_fork_target(&pr("other", "project"), "someone", origin)
11524        );
11525        assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
11526        assert_eq!(
11527            None,
11528            own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
11529        );
11530        let mut unnamed = pr("someone", "project");
11531        unnamed.head_repository_owner = None;
11532        assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
11533    }
11534
11535    /// `spar resume` and `spar checkin` on a pull request from the user's own
11536    /// fork check its branch out from the fork and push back there.
11537    #[cfg(unix)]
11538    #[test]
11539    fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
11540        let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
11541        test_git(
11542            &work,
11543            &[
11544                "push",
11545                "-q",
11546                fork.to_str().unwrap(),
11547                "issue-7:refs/heads/issue-7",
11548            ],
11549        );
11550        let published = test_git(&work, &["rev-parse", "issue-7"]);
11551        test_git(&work, &["checkout", "-q", "main"]);
11552        let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
11553        let pr = PrView {
11554            number: 31,
11555            url: String::new(),
11556            title: String::new(),
11557            body: String::new(),
11558            head_ref_name: "issue-7".into(),
11559            base_ref_name: "main".into(),
11560            state: "OPEN".into(),
11561            closing_issues_references: Vec::new(),
11562            is_cross_repository: true,
11563            head_repository_owner: None,
11564            head_repository: None,
11565        };
11566
11567        let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
11568
11569        assert_eq!("issue-7", branch);
11570        assert_eq!(
11571            published.trim(),
11572            test_git(&path, &["rev-parse", "HEAD"]).trim()
11573        );
11574        std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
11575        test_git(&path, &["commit", "-qam", "Answer a comment"]);
11576        repo.push_to(&path, &target, &branch, Some(published.trim()))
11577            .unwrap();
11578        assert_eq!(
11579            test_git(&path, &["rev-parse", "HEAD"]).trim(),
11580            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11581        );
11582        assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11583            .trim()
11584            .is_empty());
11585    }
11586
11587    #[cfg(unix)]
11588    #[test]
11589    fn with_forking_off_a_refused_push_says_why_and_how() {
11590        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
11591
11592        let err = repo
11593            .publish_new_branch(&work, "issue-7", "main")
11594            .unwrap_err()
11595            .to_string();
11596
11597        assert!(
11598            err.contains("Permission to example/project.git denied to someone."),
11599            "{err}"
11600        );
11601        assert!(err.contains("fork_when_denied = true"), "{err}");
11602        assert!(test_git(&fork, &["branch", "--list", "issue-7"])
11603            .trim()
11604            .is_empty());
11605        assert_eq!(
11606            WriteSummary {
11607                attempted: 1,
11608                failed: 1
11609            },
11610            repo.write_summary()
11611        );
11612    }
11613}
11614
11615#[cfg(test)]
11616mod comment_page_tests {
11617    use super::*;
11618
11619    #[test]
11620    fn a_single_merged_array_is_read() {
11621        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
11622        assert_eq!(2, pages.len());
11623        assert_eq!(Some(2), pages[1]["id"].as_i64());
11624    }
11625
11626    #[test]
11627    fn concatenated_pages_from_an_older_gh_are_read_too() {
11628        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
11629        assert_eq!(2, pages.len());
11630    }
11631
11632    /// A comment body containing a bracket pair used to split the payload into
11633    /// two invalid halves, so no state comment was found and a resume silently
11634    /// started from round one.
11635    #[test]
11636    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
11637        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
11638        let pages = parse_comment_pages(text);
11639        assert_eq!(2, pages.len(), "{pages:?}");
11640        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
11641    }
11642
11643    #[test]
11644    fn empty_output_is_no_comments_not_a_panic() {
11645        assert!(parse_comment_pages("").is_empty());
11646        assert!(parse_comment_pages("   ").is_empty());
11647        assert!(parse_comment_pages("[]").is_empty());
11648    }
11649
11650    #[test]
11651    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
11652        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
11653    }
11654
11655    #[test]
11656    fn a_write_postcheck_rejects_truncated_comment_pages() {
11657        let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
11658        assert!(
11659            error.to_string().contains("unexpected comment pages"),
11660            "{error}"
11661        );
11662    }
11663
11664    #[test]
11665    fn a_write_postcheck_rejects_empty_or_non_array_output() {
11666        assert!(try_parse_comment_pages("").is_err());
11667        assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
11668        assert!(try_parse_comment_pages("[]").is_ok());
11669    }
11670
11671    #[test]
11672    fn state_is_found_in_the_last_matching_comment() {
11673        let payload = |round: u32| {
11674            format!(
11675                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11676            )
11677        };
11678        let text = serde_json::to_string(&serde_json::json!([
11679            {"id": 1, "body": payload(1)},
11680            {"id": 2, "body": "looks good to me"},
11681            {"id": 3, "body": payload(5)},
11682        ]))
11683        .unwrap();
11684        let pages = parse_comment_pages(&text);
11685        let last = pages
11686            .iter()
11687            .rev()
11688            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
11689            .unwrap();
11690        assert_eq!(5, last.round);
11691    }
11692}
11693
11694#[cfg(test)]
11695mod linked_pr_tests {
11696    use super::*;
11697
11698    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
11699    /// It carries an id and a whole repository object per linked issue, and a
11700    /// parser that chokes on those reports "no pull request", which is the one
11701    /// answer that makes spar implement over the top of somebody's work.
11702    const REAL_PAYLOAD: &str = r#"[
11703      {"number":14252,"title":"fix: reject leading-dash branch names",
11704       "url":"https://github.com/cli/cli/pull/14252",
11705       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
11706         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11707         "url":"https://github.com/cli/cli/issues/14238"}]},
11708      {"number":14217,"title":"another change",
11709       "url":"https://github.com/cli/cli/pull/14217",
11710       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
11711         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11712         "url":"https://github.com/cli/cli/issues/9761"}]},
11713      {"number":14200,"title":"unlinked work",
11714       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
11715    ]"#;
11716
11717    #[test]
11718    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
11719        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
11720        assert_eq!(14252, pr.number);
11721        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
11722    }
11723
11724    #[test]
11725    fn the_right_pr_is_picked_out_of_several() {
11726        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
11727    }
11728
11729    #[test]
11730    fn an_issue_nobody_is_working_on_finds_nothing() {
11731        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
11732    }
11733
11734    #[test]
11735    fn an_unlinked_pr_is_never_matched() {
11736        // 14200 closes nothing, so no issue number should ever return it.
11737        for issue in [14200, 0, 1] {
11738            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
11739                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
11740            }
11741        }
11742    }
11743
11744    #[test]
11745    fn empty_or_broken_output_is_none_rather_than_a_panic() {
11746        assert!(find_linked_pr("", 1).is_none());
11747        assert!(find_linked_pr("[]", 1).is_none());
11748        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
11749        assert!(find_linked_pr("[{\"number\":", 1).is_none());
11750    }
11751
11752    /// "gh could not answer" and "nobody is working on it" are different
11753    /// answers, and only one of them means implement from scratch.
11754    ///
11755    /// The parse used to fold both into `None`, so an authentication failure or
11756    /// a rate limit read as an empty list and spar opened a second pull request
11757    /// for an issue somebody already had work open on.
11758    #[test]
11759    fn an_unreadable_list_is_not_an_empty_list() {
11760        assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
11761        assert!(linked_pr_rows("[{\"number\":").is_none());
11762        assert!(linked_pr_rows("").is_none());
11763
11764        let empty = linked_pr_rows("[]").expect("an empty list is an answer");
11765        assert!(empty.is_empty());
11766        assert!(pick_linked_pr(&empty, 14238).is_none());
11767
11768        let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
11769        assert_eq!(3, listed.len());
11770        assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
11771    }
11772
11773    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
11774    #[test]
11775    fn pr_view_reads_the_cross_repository_flag() {
11776        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
11777                       "baseRefName":"main","state":"OPEN",
11778                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
11779        let pr: PrView = serde_json::from_str(json).unwrap();
11780        assert!(pr.is_cross_repository);
11781        assert!(pr.is_open());
11782
11783        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
11784        assert!(
11785            !serde_json::from_str::<PrView>(&same_repo)
11786                .unwrap()
11787                .is_cross_repository
11788        );
11789    }
11790}
11791
11792#[cfg(test)]
11793mod min_number_tests {
11794    /// The floor is applied before the cap, which is the order that matters.
11795    /// spar takes the *lowest* numbered open items, so a repository with a tail
11796    /// of old issues would otherwise spend its whole run in the tail: the cap
11797    /// would be filled by the oldest items and the floor would never be
11798    /// reached. Filtering first is what makes the setting do anything.
11799    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
11800        let mut numbers: Vec<i64> = open.to_vec();
11801        numbers.sort_unstable();
11802        if min_number > 0 {
11803            numbers.retain(|n| *n >= min_number);
11804        }
11805        numbers.truncate(limit);
11806        numbers
11807    }
11808
11809    #[test]
11810    fn the_floor_is_applied_before_the_cap_not_after() {
11811        let open = [12, 13, 14, 480, 481, 482];
11812        assert_eq!(vec![480, 481], pick(&open, 2, 480));
11813        // Capping first would have returned the two oldest and then filtered
11814        // them all away, leaving nothing.
11815        assert!(!pick(&open, 2, 480).is_empty());
11816    }
11817
11818    #[test]
11819    fn no_floor_keeps_the_old_behaviour() {
11820        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
11821    }
11822
11823    #[test]
11824    fn the_floor_is_inclusive() {
11825        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
11826    }
11827
11828    #[test]
11829    fn a_floor_above_everything_open_yields_nothing() {
11830        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
11831    }
11832}
11833
11834#[cfg(test)]
11835mod review_record_tests {
11836    use super::*;
11837    use serde_json::json;
11838
11839    #[test]
11840    fn prune_state_knows_which_files_belong_to_a_pull_request() {
11841        assert_eq!(Some(12), state_file_number("pr-12.json"));
11842        assert_eq!(Some(12), state_file_number("review-12.json"));
11843        assert_eq!(Some(12), state_file_number("checkin-12.json"));
11844        for shared in [
11845            "spend.json",
11846            "implementors.json",
11847            "contested.json",
11848            "listen.json",
11849            "pr-.json",
11850            "pr-12.json.tmp",
11851            "pr-x.json",
11852        ] {
11853            assert_eq!(None, state_file_number(shared), "{shared}");
11854        }
11855    }
11856
11857    #[test]
11858    fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
11859        let reviews = vec![
11860            json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
11861            json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
11862        ];
11863        assert!(has_viewer_review(&reviews, "me", "the body", "aaa"));
11864        assert!(!has_viewer_review(&reviews, "me", "other", "aaa"));
11865        assert!(!has_viewer_review(&reviews, "alice", "the body", "aaa"));
11866    }
11867
11868    /// A clean review says the same thing on every commit. Taking the approval
11869    /// of an older commit for this one left the new head unapproved while the
11870    /// record said it was.
11871    #[test]
11872    fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
11873        let older = vec![
11874            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
11875        ];
11876        assert!(!has_viewer_review(&older, "me", "the body", "bbb"));
11877        let dismissed = vec![
11878            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
11879        ];
11880        assert!(!has_viewer_review(&dismissed, "me", "the body", "bbb"));
11881    }
11882
11883    #[test]
11884    fn a_response_splits_into_status_headers_and_body() {
11885        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";
11886        let resp = parse_api_response(text).expect("a response");
11887        assert_eq!(200, resp.status);
11888        assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
11889        assert_eq!(
11890            Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
11891            resp.headers.get("last-modified")
11892        );
11893        assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
11894
11895        let not_modified =
11896            parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
11897        assert_eq!(304, not_modified.status);
11898        assert!(not_modified.body.is_empty());
11899
11900        assert_eq!(None, parse_api_response(""));
11901        assert_eq!(None, parse_api_response("gh: command not found\n"));
11902    }
11903
11904    #[test]
11905    fn review_requests_are_read_off_the_timeline_with_who_and_when() {
11906        let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
11907            {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
11908            {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
11909            {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
11910            {}
11911        ]}}}}})
11912        .to_string();
11913        let made = parse_review_requests(&text).expect("requests");
11914        assert_eq!(2, made.len());
11915        assert_eq!("2026-10-20T10:00:00Z", made[0].at);
11916        assert_eq!("viewer", made[0].who.login);
11917        assert!(made[0].who.slug.is_empty());
11918        assert_eq!("2026-10-20T11:00:00Z", made[1].at);
11919        assert!(made[1].who.login.is_empty());
11920        assert_eq!("example/ours", made[1].who.slug);
11921
11922        let none =
11923            json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
11924        assert_eq!(
11925            Some(0),
11926            parse_review_requests(&none.to_string()).map(|m| m.len())
11927        );
11928        assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
11929        assert!(parse_review_requests("gh: not json").is_none());
11930    }
11931
11932    #[test]
11933    fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
11934        let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
11935        let error = crate::error::SparError::call_failed("gh: boom");
11936        assert!(
11937            reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(landed)).is_ok()
11938        );
11939
11940        let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
11941        let err = reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(missing))
11942            .unwrap_err();
11943        assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
11944
11945        let unreadable = Err(crate::error::SparError::new("no network"));
11946        let err = reconcile_review_post(5, "the body", "me", "aaa", error, unreadable).unwrap_err();
11947        assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
11948        assert!(err.to_string().contains("could not be verified"), "{err}");
11949    }
11950    #[test]
11951    fn a_review_with_line_comments_names_the_new_side_and_a_range_only_when_it_has_one() {
11952        let comments = vec![
11953            InlineComment {
11954                path: "src/a.rs".into(),
11955                line: 12,
11956                start_line: None,
11957                body: "one".into(),
11958            },
11959            InlineComment {
11960                path: "src/b.rs".into(),
11961                line: 9,
11962                start_line: Some(4),
11963                body: "two".into(),
11964            },
11965        ];
11966        assert_eq!(
11967            json!({
11968                "commit_id": "abc",
11969                "event": "COMMENT",
11970                "body": "the body",
11971                "comments": [
11972                    {"path": "src/a.rs", "line": 12, "side": "RIGHT", "body": "one"},
11973                    {"path": "src/b.rs", "line": 9, "side": "RIGHT", "start_line": 4,
11974                     "start_side": "RIGHT", "body": "two"},
11975                ],
11976            }),
11977            review_payload("abc", "COMMENT", "the body", &comments)
11978        );
11979    }
11980
11981    #[test]
11982    fn the_new_side_of_each_hunk_is_read_and_its_lines_are_never_taken_for_headers() {
11983        let diff = r#"diff --git a/src/a.rs b/src/a.rs
11984index 1111111..2222222 100644
11985--- a/src/a.rs
11986+++ b/src/a.rs
11987@@ -1,3 +1,4 @@ fn x
11988 one
11989+++ an added line that looks like a header
11990 two
11991 three
11992@@ -20 +21,2 @@
11993-old
11994+new
11995+more
11996@@ -30,1 +32,0 @@
11997-gone
11998diff --git a/gone.rs b/gone.rs
11999deleted file mode 100644
12000--- a/gone.rs
12001+++ /dev/null
12002@@ -1,2 +0,0 @@
12003-a
12004-b
12005diff --git a/old.rs b/renamed.rs
12006similarity index 90%
12007rename from old.rs
12008rename to renamed.rs
12009--- a/old.rs
12010+++ b/renamed.rs
12011@@ -5,0 +6,1 @@
12012+inserted
12013diff --git a/with space.rs b/with space.rs
12014--- a/with space.rs	
12015+++ b/with space.rs	
12016@@ -2 +2 @@
12017-x
12018+y
12019diff --git "a/sp\tace.rs" "b/sp\tace.rs"
12020--- "a/sp\tace.rs"
12021+++ "b/sp\tace.rs"
12022@@ -1 +1 @@
12023-x
12024+y
12025"#;
12026        let hunks = new_side_hunks(diff);
12027        assert_eq!(
12028            Hunks::from([
12029                ("renamed.rs".to_string(), vec![(6, 6)]),
12030                ("src/a.rs".to_string(), vec![(1, 4), (21, 22)]),
12031                ("with space.rs".to_string(), vec![(2, 2)]),
12032            ]),
12033            hunks
12034        );
12035    }
12036
12037    #[test]
12038    fn only_a_refusal_is_final_enough_to_send_a_review_again() {
12039        let error = |text: &str| crate::error::SparError::new(text);
12040        assert!(refused(&error(
12041            "command failed (1): gh api\ngh: Validation Failed (HTTP 422)"
12042        )));
12043        assert!(!refused(&error("gh: Bad Gateway (HTTP 502)")));
12044        assert!(!refused(&error("gh: HTTP 504: Gateway Timeout")));
12045        assert!(!refused(&error("command timed out after 300s")));
12046    }
12047
12048    #[test]
12049    fn a_hunk_header_with_its_counts_left_out_counts_one() {
12050        assert_eq!(Some(((20, 1), (21, 2))), hunk_header("@@ -20 +21,2 @@"));
12051        assert_eq!(Some(((1, 3), (1, 1))), hunk_header("@@ -1,3 +1 @@ fn x"));
12052        assert_eq!(None, hunk_header("@@@ -1,3 -1,3 +1,4 @@@"));
12053        assert_eq!(None, hunk_header(" @@ -1 +1 @@"));
12054    }
12055}