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
67/// Files SPAR writes into a worktree itself, which no agent ever authors.
68pub(crate) const RECOVERY_MARKER_PREFIX: &str = ".spar-recovery-needed-";
69pub(crate) const EDITED_GIT_MARKER: &str = ".spar-edited-git-marker";
70
71/// What SPAR is allowed to throw away when it decides a checkout can go.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub(crate) enum Disposable {
74    /// Recognized build output, and nothing else. Somebody may have put the
75    /// rest there and want it back.
76    BuildOutput,
77    /// Every ignored file, and SPAR's own markers. Only rebuilding a review
78    /// checkout qualifies, and only because the next statement replaces it with
79    /// a fresh checkout of the same ref: what is there came from an earlier
80    /// `spar review` of the same pull request, and `git worktree add` would
81    /// write over it regardless. Refusing instead left the command unusable on
82    /// any project whose build writes outside `target/`, since a single
83    /// lockfile beside a generator's manifest blocked every later run.
84    ReviewOutput,
85}
86
87/// Whether `path` is one of the files SPAR leaves in a worktree root.
88///
89/// A marker is evidence for a person, not something a call did to the tree, and
90/// two reviewers share one checkout: the marker written when the first one's
91/// answer was refused is otherwise a brand new untracked file in the snapshot
92/// the second one is compared against, so one refusal took the other review
93/// down with it.
94pub(crate) fn is_spar_marker(path: &Path) -> bool {
95    let mut components = path.components();
96    let Some(std::path::Component::Normal(name)) = components.next() else {
97        return false;
98    };
99    if components.next().is_some() {
100        return false;
101    }
102    let Some(name) = name.to_str() else {
103        return false;
104    };
105    name == EDITED_GIT_MARKER || name.starts_with(RECOVERY_MARKER_PREFIX)
106}
107
108/// Whether `path` is somewhere SPAR writes in the repository it is driving.
109///
110/// The markers are one part of it. The other is the run state under `.spar/`,
111/// which SPAR rewrites while a call it started is still running: recording the
112/// branches it created during a parallel triage is enough to make both agents
113/// look like they changed the tree they were told only to read. SPAR's own
114/// bookkeeping is not evidence about an agent, so it never enters the snapshot
115/// a call is compared against.
116///
117/// `.spar-worktrees/` is deliberately not here. A resident worktree enters the
118/// snapshot as one ignored entry so that another run's churn inside it does not
119/// disturb this baseline, while deleting the whole checkout still does.
120pub(crate) fn is_spar_own_path(path: &Path) -> bool {
121    if is_spar_marker(path) {
122        return true;
123    }
124    let Some(std::path::Component::Normal(name)) = path.components().next() else {
125        return false;
126    };
127    name == OsStr::new(STATE_DIR)
128}
129
130/// How many names one part of a split may be tried on before giving up. High
131/// enough that nobody reaches it by splitting the same pull request again, low
132/// enough that a repository where every name is taken says so rather than
133/// looping.
134const SPLIT_SLOTS: u32 = 20;
135
136/// The longest `[loop] setup` may run, as long as the check gets. An install
137/// that has not finished in half an hour is stuck rather than slow.
138const SETUP_TIMEOUT_SECS: u64 = 1800;
139
140#[derive(Debug, Clone)]
141pub struct SplitPushError {
142    message: String,
143    retain_worktree: bool,
144}
145
146impl SplitPushError {
147    pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
148        Self {
149            message: message.into(),
150            retain_worktree,
151        }
152    }
153
154    pub fn retain_worktree(&self) -> bool {
155        self.retain_worktree
156    }
157}
158
159impl std::fmt::Display for SplitPushError {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        f.write_str(&self.message)
162    }
163}
164
165impl std::error::Error for SplitPushError {}
166
167/// The branch and worktree name for one part, on its `attempt`th name.
168///
169/// The unsuffixed name first, so the ordinary case reads as `split-12-1` and
170/// only a repeat split carries a suffix.
171fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
172    match attempt {
173        1 => format!("split-{parent}-{index}"),
174        n => format!("split-{parent}-{index}-{n}"),
175    }
176}
177
178/// Where a branch spar opened a pull request from is published.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum PushTarget {
181    Origin,
182    /// The authenticated user's fork, for a repository origin would not let
183    /// them push to.
184    Fork(Fork),
185}
186
187/// A fork of this repository that spar publishes branches to.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct Fork {
190    /// The account that owns it, which `gh pr create --head` needs in front of
191    /// the branch name.
192    pub owner: String,
193    /// `owner/name`, for saying where a branch went.
194    pub full_name: String,
195    /// The SSH or HTTPS URL, whichever origin uses, pushed to directly so the
196    /// checkout's remotes are left as they were.
197    pub url: String,
198}
199
200impl PushTarget {
201    /// What `gh pr create --head` takes for a branch published here.
202    pub fn head(&self, branch: &str) -> String {
203        match self {
204            PushTarget::Origin => branch.to_string(),
205            PushTarget::Fork(fork) => format!("{}:{branch}", fork.owner),
206        }
207    }
208
209    fn remote(&self) -> &str {
210        match self {
211            PushTarget::Origin => "origin",
212            PushTarget::Fork(fork) => &fork.url,
213        }
214    }
215
216    fn describe(&self, branch: &str) -> String {
217        match self {
218            PushTarget::Origin => format!("origin/{branch}"),
219            PushTarget::Fork(fork) => format!("{branch} on the fork {}", fork.full_name),
220        }
221    }
222}
223
224#[derive(Debug)]
225pub struct Repo {
226    root: PathBuf,
227    pub style: Style,
228    pub branch_prefix: String,
229    pub state_store: StateStore,
230    pub followups: Followups,
231    pub drafts: Drafts,
232    /// Publish a new branch from a fork when origin refuses the push.
233    pub fork_when_denied: bool,
234    /// What `[loop] setup` runs in each worktree spar creates.
235    setup: CheckCommand,
236    /// The fork new branches go to once origin has refused one, found at most
237    /// once.
238    fork: OnceLock<Fork>,
239    /// The login `gh` is authenticated as, asked at most once.
240    ///
241    /// `OnceLock` rather than `OnceCell` because `&Repo` crosses a
242    /// `std::thread::scope` whenever both agents are asked at the same time,
243    /// and only `OnceLock` is `Sync`.
244    viewer: OnceLock<String>,
245    /// Highest persisted checkpoint observed for each pull request.
246    ///
247    /// Kept in memory so a transient state read cannot reset the sequence
248    /// after a resume already loaded a newer checkpoint.
249    checkpoints: Mutex<BTreeMap<i64, u64>>,
250    writes: WriteStats,
251}
252
253#[derive(Debug, Default)]
254struct WriteStats {
255    attempted: AtomicUsize,
256    failed: AtomicUsize,
257}
258
259#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
260pub(crate) struct WriteSummary {
261    pub(crate) attempted: usize,
262    pub(crate) failed: usize,
263}
264
265impl WriteSummary {
266    pub(crate) fn succeeded(self) -> usize {
267        self.attempted.saturating_sub(self.failed)
268    }
269}
270
271/// Ignored, untracked paths present before an editing call starts.
272///
273/// Existing build artifacts are deliberately part of the baseline. Callers can
274/// therefore distinguish them from an ignored file the editing call created
275/// and avoid deleting the latter as if no work had happened.
276#[derive(Debug, Clone)]
277pub(crate) struct WorktreeBaseline {
278    attributes: AttributeState,
279    ignored_untracked: IgnoredState,
280    git_state: GitState,
281}
282
283/// The recorded Git state of a worktree that must remain read only.
284///
285/// Read-only agent calls are still ordinary processes. Capturing the complete
286/// working state lets callers refuse to publish their answer or delete the
287/// checkout if a call writes despite its instructions.
288#[derive(Debug, Clone)]
289pub(crate) struct WorktreeCheckpoint {
290    path: PathBuf,
291    attributes: AttributeState,
292    git_state: GitState,
293    ignored_untracked: IgnoredState,
294}
295
296#[derive(Debug, Clone, Default, PartialEq, Eq)]
297pub(crate) struct AttributeState {
298    files: BTreeMap<PathBuf, [u8; 32]>,
299}
300
301/// Exact paths and fingerprints for every untracked file, plus ignored paths.
302///
303/// Paths stay as operating-system strings so a non-UTF-8 filename cannot be
304/// merged with another path by lossy command-output conversion.
305#[derive(Debug, Clone, Default, PartialEq, Eq)]
306pub(crate) struct IgnoredState {
307    files: BTreeMap<PathBuf, UntrackedFile>,
308    ignored: BTreeSet<PathBuf>,
309}
310
311/// A bounded-cost fingerprint for an untracked filesystem entry.
312///
313/// Content hashing every ignored compiler artifact made each checkpoint read
314/// gigabytes. File identity, type, size, timestamps, and mode detect ordinary
315/// writes without rereading build output. Unix change time also changes when a
316/// writer restores the modification time.
317#[derive(Debug, Clone, PartialEq, Eq)]
318struct UntrackedFile {
319    kind: u8,
320    len: u64,
321    modified: Option<std::time::SystemTime>,
322    created: Option<std::time::SystemTime>,
323    readonly: bool,
324    symlink_target: Option<Vec<u8>>,
325    #[cfg(unix)]
326    device: u64,
327    #[cfg(unix)]
328    inode: u64,
329    #[cfg(unix)]
330    mode: u32,
331    #[cfg(unix)]
332    change_seconds: i64,
333    #[cfg(unix)]
334    change_nanoseconds: i64,
335}
336
337#[derive(Debug, Clone, Default, PartialEq, Eq)]
338pub(crate) struct GitState {
339    repositories: BTreeMap<PathBuf, RepositoryState>,
340}
341
342#[derive(Debug, Clone, PartialEq, Eq)]
343struct RepositoryState {
344    head: String,
345    unsafe_index_flags: Vec<u8>,
346    tracked: BTreeMap<PathBuf, TrackedEntry>,
347    gitlinks: BTreeMap<PathBuf, String>,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq)]
351struct TrackedEntry {
352    index_mode: String,
353    index_oid: String,
354    worktree: Option<WorktreeFile>,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq)]
358struct WorktreeFile {
359    mode: String,
360    #[cfg(unix)]
361    permissions: u32,
362    raw_oid: String,
363    fingerprint: [u8; 32],
364    content: [u8; 32],
365}
366
367struct Gitlink {
368    path: PathBuf,
369    oid: String,
370}
371
372struct IndexEntry {
373    path: PathBuf,
374    mode: String,
375    oid: String,
376}
377
378impl IgnoredState {
379    fn is_ignored(&self, path: &Path) -> bool {
380        self.ignored.contains(path)
381    }
382
383    fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
384        let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
385        paths.extend(after.files.keys().cloned());
386        paths
387            .into_iter()
388            .filter(|path| {
389                self.files.get(path) != after.files.get(path)
390                    || self.is_ignored(path) != after.is_ignored(path)
391            })
392            .collect()
393    }
394
395    fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
396        self.files
397            .iter()
398            .filter(|(path, state)| after.files.get(*path) != Some(*state))
399            .map(|(path, _)| path.clone())
400            .collect()
401    }
402
403    fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
404        after
405            .files
406            .keys()
407            .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
408            .cloned()
409            .collect()
410    }
411
412    /// Whether anything moved here that is not recognized build or cache
413    /// output.
414    ///
415    /// A read-only call is held to its word by comparing the worktree before
416    /// and after. Verifying a finding usually means building the project and
417    /// running its tests, which rewrites that output, and holding a reviewer to
418    /// a byte-identical `dist/` threw away the review it had just done. What
419    /// the call was asked to judge is the tracked tree, and that is compared as
420    /// strictly as ever, along with every other untracked file.
421    pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
422        let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
423        paths.extend(after.files.keys());
424        paths.into_iter().any(|path| {
425            if self.files.get(path) == after.files.get(path)
426                && self.is_ignored(path) == after.is_ignored(path)
427            {
428                return false;
429            }
430            !self.generated_move(after, path)
431        })
432    }
433
434    /// What a read-only call left behind, split by whether it condemns the
435    /// answer.
436    ///
437    /// `changed_beyond_generated` answers this from a list of directory names,
438    /// which is a guess about where a build writes. The repository already
439    /// states where it writes, in its own ignore rules, and a review died over
440    /// the gap: a reviewer ran the project's binding generator to check a
441    /// finding, that wrote a lockfile beside its own manifest rather than under
442    /// `target/`, and both agents lost an hour of work to a path Git had been
443    /// told to disown.
444    ///
445    /// Forgiving only the creation of such a path was not enough, because the
446    /// call is not the only writer. A read-only call runs for minutes in the
447    /// primary checkout, which is a live desk: Finder rewrites `.DS_Store`, an
448    /// editor rewrites its own state, and a triage that asks two agents at once
449    /// compares both of them against that same directory. One ambient write
450    /// discarded two good answers and ended the run with nothing scheduled. So
451    /// a path the project ignores is out of scope however it moved, and what
452    /// remains fatal is what the call was actually asked about: the tracked
453    /// tree, and any ordinary untracked file, because a reviewer was told to
454    /// keep its scratch out of the worktree.
455    ///
456    /// Nothing is silent. A tolerated path that is not recognized build output
457    /// is named by the caller, so an agent that rewrote a person's `.env` is
458    /// reported rather than hidden. That is the trade this makes: the answer of
459    /// a call that never commits is worth more than the guarantee, and the
460    /// warning carries what the guarantee used to.
461    pub(crate) fn read_only_leavings(&self, after: &Self) -> ReadOnlyLeavings {
462        let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
463        paths.extend(after.files.keys());
464        let mut leavings = ReadOnlyLeavings::default();
465        for path in paths {
466            if self.files.get(path) == after.files.get(path)
467                && self.is_ignored(path) == after.is_ignored(path)
468            {
469                continue;
470            }
471            if !self.ignored_file_move(after, path) {
472                leavings.condemning.push(path.clone());
473            } else if !is_generated_artifact(path) {
474                leavings.tolerated.push(path.clone());
475            }
476        }
477        leavings
478    }
479
480    /// Whether what happened at `path` between these two states is a build or
481    /// cache directory doing its job, rather than a change a person would miss.
482    ///
483    /// Removal counts, on either side. A compiler that keeps an incremental
484    /// cache does not only add and rewrite: it finalizes a new session
485    /// directory and deletes the one it superseded, lock file and all. A rule
486    /// that forgave creation and rewriting but not removal therefore passed the
487    /// first build in a fresh worktree and failed the second one, which is the
488    /// build a reviewer runs to check a finding. Two of these rules disagreeing
489    /// about the same directory is what cost the run: the read-only check
490    /// forgave the deleted session and the commit-side check called it
491    /// unrepresentable, so the work was already on a pull request when the run
492    /// ended. There is one rule now, and this is it.
493    fn generated_move(&self, after: &Self, path: &Path) -> bool {
494        is_generated_artifact(path) && self.disposable_move(after, path)
495    }
496
497    /// Successful edits and read-only calls share an ignore policy. Spar's
498    /// resident worktrees belong to other runs, not to this call's output.
499    /// Cleanup separately preserves ignored data, including dependency clones.
500    fn ignored_file_move(&self, after: &Self, path: &Path) -> bool {
501        if path.starts_with(WORKTREE_DIR)
502            && self.files.get(path).is_some_and(|file| file.kind == 3)
503            && self.files.get(path) != after.files.get(path)
504        {
505            return false;
506        }
507        self.disposable_move(after, path)
508    }
509
510    /// Whether neither state has anything at `path` worth keeping, so whatever
511    /// happened there took nothing with it.
512    fn disposable_move(&self, after: &Self, path: &Path) -> bool {
513        self.disposable(path) && after.disposable(path)
514    }
515
516    /// Whether this state has nothing at `path` worth keeping: either the path
517    /// is not there at all, or it is there as an ignored file.
518    fn disposable(&self, path: &Path) -> bool {
519        !self.files.contains_key(path) || self.is_ignored(path)
520    }
521}
522
523impl GitState {
524    /// What moved between two states, said so a person can act on it.
525    ///
526    /// The guards that compare these used to report only that the worktree had
527    /// changed, which left the one question worth answering, which file, to a
528    /// `git status` run by hand against a tree that had moved on since.
529    pub(crate) fn describe_difference(&self, after: &Self) -> Option<String> {
530        let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
531        prefixes.extend(after.repositories.keys());
532        let mut notes: Vec<String> = Vec::new();
533        let mut paths: Vec<PathBuf> = Vec::new();
534        for prefix in prefixes {
535            let label = describe_repository(prefix);
536            match (
537                self.repositories.get(prefix),
538                after.repositories.get(prefix),
539            ) {
540                (None, Some(_)) => notes.push(format!("a repository appeared at {label}")),
541                (Some(_), None) => notes.push(format!("the repository at {label} is gone")),
542                (Some(before), Some(current)) => {
543                    if before.head != current.head {
544                        notes.push(format!("HEAD moved in {label}"));
545                    }
546                    if before.unsafe_index_flags != current.unsafe_index_flags {
547                        notes.push(format!("index flags changed in {label}"));
548                    }
549                    paths.extend(
550                        differing_keys(&before.gitlinks, &current.gitlinks)
551                            .chain(differing_keys(&before.tracked, &current.tracked))
552                            .map(|path| prefix.join(path)),
553                    );
554                }
555                (None, None) => {}
556            }
557        }
558        if !paths.is_empty() {
559            paths.sort();
560            paths.dedup();
561            notes.push(format!("tracked file(s) changed: {}", list_paths(&paths)));
562        }
563        (!notes.is_empty()).then(|| notes.join("; "))
564    }
565
566    /// The tracked half of what a read-only call left behind, with a checkout
567    /// that moved under the call reported rather than held against it.
568    pub(crate) fn read_only_difference(&self, after: &Self, cwd: &Path) -> Option<String> {
569        let difference = self.describe_difference(after)?;
570        let Some(moved) = self.moved_wholesale(after) else {
571            return Some(difference);
572        };
573        // Once, however many calls were reading when it happened: two triage
574        // agents share the checkout and would otherwise both report the move.
575        if said_once("moved", cwd, &moved) {
576            logwarn!(
577                "the checkout at {} moved while a read-only call was reading it: {moved}. The \
578                 answer was kept, and it describes the tree as it stood before the move.",
579                cwd.display()
580            );
581        }
582        None
583    }
584
585    /// Whether every difference between these two states is the checkout moving
586    /// as a whole rather than the call writing into it.
587    ///
588    /// The primary checkout is a live desk and a read-only call reads it for
589    /// minutes. A person or their editor pulls, the branch fast forwards, and
590    /// every file that arrived with it now differs from the snapshot taken
591    /// before the call. Holding the call to that snapshot threw away two good
592    /// triage answers and ended a run with nothing scheduled, over a change the
593    /// call had no part in.
594    ///
595    /// The index tells the two apart. A call that wrote leaves a path
596    /// disagreeing with the index, staged or unstaged, and leaves HEAD where it
597    /// was. A checkout that moved rewrote the index and the worktree together,
598    /// so every path it touched agrees with the index, and it moved HEAD to say
599    /// so. Anything else, a tracked file that changed while HEAD stood still
600    /// most of all, is the call's to answer for.
601    fn moved_wholesale(&self, after: &Self) -> Option<String> {
602        let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
603        prefixes.extend(after.repositories.keys());
604        let mut notes: Vec<String> = Vec::new();
605        for prefix in prefixes {
606            // A repository that appeared or vanished is not a checkout moving.
607            let before = self.repositories.get(prefix)?;
608            let current = after.repositories.get(prefix)?;
609            if before.unsafe_index_flags != current.unsafe_index_flags
610                || before.gitlinks != current.gitlinks
611            {
612                return None;
613            }
614            let moved: Vec<&PathBuf> = differing_keys(&before.tracked, &current.tracked).collect();
615            if before.head == current.head {
616                if moved.is_empty() {
617                    continue;
618                }
619                return None;
620            }
621            if !moved.iter().all(|path| {
622                checked_out(before.tracked.get(*path)) && checked_out(current.tracked.get(*path))
623            }) {
624                return None;
625            }
626            let label = describe_repository(prefix);
627            notes.push(match moved.len() {
628                0 => format!("HEAD moved in {label}"),
629                count => format!("HEAD moved in {label}, carrying {count} tracked file(s)"),
630            });
631        }
632        (!notes.is_empty()).then(|| notes.join("; "))
633    }
634}
635
636/// Whether a tracked path holds exactly what the index says it holds, so
637/// nothing there is staged or left unstaged.
638///
639/// A path the index no longer lists counts as checked out: only an index
640/// operation drops one, and a file left in the worktree after that is an
641/// ordinary untracked file, which the untracked half of the same check has.
642fn checked_out(entry: Option<&TrackedEntry>) -> bool {
643    entry.is_none_or(|entry| {
644        entry
645            .worktree
646            .as_ref()
647            .is_some_and(|file| file.raw_oid == entry.index_oid && file.mode == entry.index_mode)
648    })
649}
650
651/// Say what a call left among paths the project ignores, once per run for a
652/// given set of them.
653///
654/// The project's own ignore rules put these outside what the call was asked
655/// about, so this is a note about the desk the call ran on rather than a
656/// finding about the call. Said as a warning on every call, it became the
657/// loudest thing in the log of every run in a checkout with an editor open in
658/// it: `.DS_Store` twice per triage, and again for each review. It still names
659/// the paths, so an agent that rewrote a person's `.env` is reported rather
660/// than hidden.
661pub(crate) fn note_ignored_leavings(cwd: &Path, what: &str, paths: &[PathBuf]) {
662    if paths.is_empty() {
663        return;
664    }
665    let named = list_paths(paths);
666    if !said_once("ignored", cwd, &named) {
667        return;
668    }
669    logdim!(
670        "a read-only {what} left {} ignored file(s) changed in {}: {named}. It was kept; the \
671         project ignores these paths.",
672        paths.len(),
673        cwd.display()
674    );
675}
676
677/// Whether this run has yet to say this about this directory.
678///
679/// A run reads the same checkout dozens of times, and the ambient facts about
680/// it, an editor's own files moving and a branch advancing under the reader,
681/// are true for every one of those calls. Said once they are context; said
682/// each time they are the log.
683fn said_once(topic: &str, cwd: &Path, detail: &str) -> bool {
684    static SAID: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
685    SAID.lock()
686        .unwrap_or_else(std::sync::PoisonError::into_inner)
687        .insert(format!("{topic}\0{}\0{detail}", cwd.display()))
688}
689
690/// Name a repository inside a state for a report. The root carries an empty
691/// prefix, and calling that "" helps nobody.
692fn describe_repository(prefix: &Path) -> String {
693    if prefix.as_os_str().is_empty() {
694        "the worktree".to_string()
695    } else {
696        format!("the submodule at {:?}", prefix.as_os_str())
697    }
698}
699
700/// The keys whose values differ between two maps, present on either side.
701fn differing_keys<'a, V: PartialEq>(
702    before: &'a BTreeMap<PathBuf, V>,
703    after: &'a BTreeMap<PathBuf, V>,
704) -> impl Iterator<Item = &'a PathBuf> {
705    let mut keys: BTreeSet<&'a PathBuf> = before.keys().collect();
706    keys.extend(after.keys());
707    keys.into_iter()
708        .filter(move |key| before.get(*key) != after.get(*key))
709}
710
711/// What a read-only call left in the untracked and ignored tree.
712///
713/// Split rather than reduced to a yes or no, because the two halves are
714/// answered differently: one ends the call, the other is said out loud and the
715/// answer is kept.
716#[derive(Default)]
717pub(crate) struct ReadOnlyLeavings {
718    /// Disposable on both sides, so out of scope for a call that only read.
719    /// Recognized build output is left out: rebuilding is what a reviewer was
720    /// asked to do, and nothing reports it today.
721    pub(crate) tolerated: Vec<PathBuf>,
722    /// Everything else. The answer does not survive these.
723    pub(crate) condemning: Vec<PathBuf>,
724}
725
726/// Put two accounts of what moved into one sentence, keeping whichever of them
727/// there is. Both guards that report a changed worktree ask the tracked tree
728/// and the untracked tree separately, and either can be the only answer.
729pub(crate) fn join_reasons(first: Option<String>, second: Option<String>) -> Option<String> {
730    match (first, second) {
731        (None, None) => None,
732        (Some(one), None) | (None, Some(one)) => Some(one),
733        (Some(first), Some(second)) => Some(format!("{first}; {second}")),
734    }
735}
736
737/// Name some paths for a person, without printing a build directory at them.
738///
739/// Every report of a path that stopped or was let through a call goes through
740/// this, so an error and the warning beside it read alike.
741pub(crate) fn list_paths(paths: &[PathBuf]) -> String {
742    const SHOWN: usize = 5;
743    let mut listed = paths
744        .iter()
745        .take(SHOWN)
746        .map(|path| format!("{:?}", path.as_os_str()))
747        .collect::<Vec<_>>()
748        .join(", ");
749    if paths.len() > SHOWN {
750        listed.push_str(&format!(", and {} more", paths.len() - SHOWN));
751    }
752    listed
753}
754
755/// Ignored files one commit attempt let through, gathered for a single report.
756///
757/// The checks that allow it run more than once per attempt, before staging,
758/// after staging, and again after the commit, because the tree could have moved
759/// under any of them. Reporting from inside each check said the same thing
760/// about the same files two and three times over.
761#[derive(Default)]
762struct IgnoredArtifacts {
763    new_paths: BTreeSet<PathBuf>,
764    changed_paths: BTreeSet<PathBuf>,
765    removed_paths: BTreeSet<PathBuf>,
766}
767
768impl IgnoredArtifacts {
769    fn left(&mut self, allowed: AllowedArtifacts) {
770        self.new_paths.extend(allowed.present);
771        self.removed_paths.extend(allowed.removed);
772    }
773
774    fn changed(&mut self, allowed: AllowedArtifacts) {
775        self.changed_paths.extend(allowed.present);
776        self.removed_paths.extend(allowed.removed);
777    }
778
779    /// Report paths without claiming ignored files are safe to delete.
780    fn report(&self, cwd: &Path) {
781        let paths: BTreeSet<_> = self
782            .new_paths
783            .iter()
784            .chain(&self.changed_paths)
785            .chain(&self.removed_paths)
786            .cloned()
787            .collect();
788        if !paths.is_empty() {
789            logdim!(
790                "the editing call changed {} ignored file(s) in {}: {}. They are outside \
791                 the commit; cleanup checks them separately.",
792                paths.len(),
793                cwd.display(),
794                list_paths(&paths.into_iter().collect::<Vec<_>>())
795            );
796        }
797    }
798}
799
800/// Ignored files a check let through, split by whether each one
801/// is still on disk, so the report can say what happened rather than call a
802/// deleted incremental session a file that was left behind.
803#[derive(Default)]
804struct AllowedArtifacts {
805    present: Vec<PathBuf>,
806    removed: Vec<PathBuf>,
807}
808
809impl AllowedArtifacts {
810    fn split(paths: Vec<PathBuf>, after: &IgnoredState) -> Self {
811        let (present, removed) = paths
812            .into_iter()
813            .partition(|path| after.files.contains_key(path));
814        Self { present, removed }
815    }
816}
817
818/// Generated directories that test and build commands routinely create.
819///
820/// Files under these directories are never committed, and they do not keep a
821/// worktree that is otherwise finished, so running the requested tests neither
822/// stops the tracked change reaching review nor leaves a checkout behind.
823fn is_generated_artifact(path: &Path) -> bool {
824    const DIRECTORIES: &[&str] = &[
825        "target",
826        "dist",
827        "node_modules",
828        "__pycache__",
829        ".pytest_cache",
830        ".mypy_cache",
831        ".ruff_cache",
832        ".tox",
833        ".nox",
834        ".venv",
835        "venv",
836        ".gradle",
837        ".build",
838        "DerivedData",
839        ".next",
840        ".nuxt",
841        ".svelte-kit",
842        ".turbo",
843        "coverage",
844    ];
845    path.components().any(|component| {
846        let std::path::Component::Normal(name) = component else {
847            return false;
848        };
849        DIRECTORIES
850            .iter()
851            .any(|directory| name == OsStr::new(directory))
852    })
853}
854
855/// Files the desktop writes into any folder a person opens.
856///
857/// Finder drops `.DS_Store` into every directory it shows, so a finished
858/// worktree someone once looked at in Finder held nothing but these, and
859/// `spar clean` kept every one of them as if it were work.
860fn is_desktop_metadata(path: &Path) -> bool {
861    path.file_name() == Some(OsStr::new(".DS_Store"))
862}
863
864fn merge_pr_args<'a>(
865    number: &'a str,
866    expected_head: Option<&'a str>,
867    delete_branch: bool,
868) -> Vec<&'a str> {
869    let mut args = vec!["pr", "merge", number, "--squash"];
870    if delete_branch {
871        args.push("--delete-branch");
872    }
873    if let Some(expected_head) = expected_head {
874        args.extend(["--match-head-commit", expected_head]);
875    }
876    args
877}
878
879/// The line of a failed push where the host says this account may not write
880/// to the repository, as opposed to the branch having moved or the network
881/// failing.
882///
883/// GitHub words it the same over SSH (`ERROR: Permission to o/r.git denied to
884/// u.`) and HTTPS (`remote: Permission to ...`), and in both it comes before
885/// several lines of generic advice, so the last line is the least useful one:
886/// it used to be all spar said, and all it said was "and the repository
887/// exists."
888fn push_denial(message: &str) -> Option<&str> {
889    message
890        .lines()
891        .map(|line| {
892            let line = line.trim();
893            let line = line.strip_prefix("ERROR:").unwrap_or(line);
894            line.strip_prefix("remote:").unwrap_or(line).trim()
895        })
896        .find(|line| line.starts_with("Permission to ") && line.contains(" denied to "))
897}
898
899/// The line of a failed push that names the ref the remote turned down and
900/// why, such as `[rejected] HEAD -> issue-7 (stale info)`.
901fn push_rejection(message: &str) -> Option<&str> {
902    message
903        .lines()
904        .map(|line| line.trim().trim_start_matches('!').trim())
905        .find(|line| line.starts_with("[rejected]") || line.starts_with("[remote rejected]"))
906}
907
908fn push_error(
909    target: &PushTarget,
910    branch: &str,
911    e: &crate::error::SparError,
912) -> crate::error::SparError {
913    let message = e.to_string();
914    if let Some(denied) = push_denial(&message) {
915        return spar_err!("could not push to {}. {denied}", target.describe(branch));
916    }
917    spar_err!(
918        "could not push to {}. {}\nCheck push access and whether the branch moved under you.",
919        target.describe(branch),
920        push_rejection(&message).unwrap_or(e.last_line())
921    )
922}
923
924/// The fork's URL, reached the way origin is: origin's push URL with only its
925/// `owner/repo` path replaced.
926///
927/// Keeping everything but the path is what keeps an SSH host alias, which is
928/// how one machine pushes as two GitHub accounts, a port, such as SSH over
929/// 443 where 22 is blocked, and an scp-style address with no user in it.
930/// GitHub's own URLs for the fork would drop all three. None for anything
931/// that is not a URL or an scp-style address with an `owner/repo` path.
932fn fork_url(origin_url: &str, full_name: &str) -> Option<String> {
933    let origin = origin_url.trim();
934    let path_start = match origin.find("://") {
935        Some(scheme) => {
936            let host = scheme + 3;
937            host + origin[host..].find('/')? + 1
938        }
939        None => {
940            // scp-style is `[user@]host:path`, and git reads a colon after a
941            // slash as part of a local path instead.
942            let colon = origin.find(':')?;
943            if origin[..colon].contains('/') {
944                return None;
945            }
946            colon + 1
947        }
948    };
949    let path = origin[path_start..].trim_end_matches('/');
950    let (path, suffix) = match path.strip_suffix(".git") {
951        Some(bare) => (bare, ".git"),
952        None => (path, ""),
953    };
954    let segments: Vec<&str> = path.split('/').collect();
955    if segments.len() < 2 || segments[segments.len() - 2..].iter().any(|s| s.is_empty()) {
956        return None;
957    }
958    let prefix = &segments[..segments.len() - 2];
959    let prefix = if prefix.is_empty() {
960        String::new()
961    } else {
962        format!("{}/", prefix.join("/"))
963    };
964    Some(format!(
965        "{}{prefix}{full_name}{suffix}",
966        &origin[..path_start]
967    ))
968}
969
970/// A fork from what GitHub said about it, reached the way origin is.
971///
972/// GitHub's own URL is the fallback for an origin `fork_url` cannot read, the
973/// SSH one when origin looks like SSH.
974fn fork_reached_like_origin(
975    owner: String,
976    full_name: String,
977    origin_url: &str,
978    ssh_url: String,
979    https_url: String,
980) -> Fork {
981    let origin = origin_url.trim();
982    let url = fork_url(origin, &full_name).unwrap_or_else(|| {
983        if origin.starts_with("git@") || origin.starts_with("ssh://") {
984            ssh_url
985        } else {
986            https_url
987        }
988    });
989    Fork {
990        owner,
991        full_name,
992        url,
993    }
994}
995
996#[derive(Deserialize)]
997struct ForkOwner {
998    login: String,
999}
1000
1001/// The fork GitHub made, or already had, in answer to a request to fork.
1002fn fork_from_api(answer: &str, origin_url: &str) -> Result<Fork> {
1003    #[derive(Deserialize)]
1004    struct Answer {
1005        full_name: String,
1006        owner: ForkOwner,
1007        ssh_url: String,
1008        clone_url: String,
1009    }
1010
1011    let answer: Answer = serde_json::from_str(answer.trim())
1012        .map_err(|e| spar_err!("unexpected answer from GitHub when forking: {e}"))?;
1013    Ok(fork_reached_like_origin(
1014        answer.owner.login,
1015        answer.full_name,
1016        origin_url,
1017        answer.ssh_url,
1018        answer.clone_url,
1019    ))
1020}
1021
1022/// The authenticated user's fork from a listing of this repository's forks
1023/// that only the user owns, or None when there is no such fork.
1024fn fork_from_listing(answer: &str, origin_url: &str) -> Result<Option<Fork>> {
1025    #[derive(Deserialize)]
1026    #[serde(rename_all = "camelCase")]
1027    struct Node {
1028        name_with_owner: String,
1029        owner: ForkOwner,
1030        ssh_url: String,
1031        url: String,
1032    }
1033
1034    let nodes: Vec<Node> = serde_json::from_str(answer.trim())
1035        .map_err(|e| spar_err!("unexpected answer from GitHub listing forks: {e}"))?;
1036    Ok(nodes.into_iter().next().map(|node| {
1037        fork_reached_like_origin(
1038            node.owner.login,
1039            node.name_with_owner,
1040            origin_url,
1041            node.ssh_url,
1042            node.url,
1043        )
1044    }))
1045}
1046
1047/// Where spar can push a pull request from a fork: the fork, when it is the
1048/// authenticated user's own, and nowhere when it is anybody else's.
1049fn own_fork_target(pr: &PrView, viewer: &str, origin_url: &str) -> Option<PushTarget> {
1050    let owner = pr.head_repository_owner.as_ref()?.login.trim();
1051    let name = pr.head_repository.as_ref()?.name.trim();
1052    if owner.is_empty() || name.is_empty() || !owner.eq_ignore_ascii_case(viewer.trim()) {
1053        return None;
1054    }
1055    let full_name = format!("{owner}/{name}");
1056    Some(PushTarget::Fork(Fork {
1057        owner: owner.to_string(),
1058        url: fork_url(origin_url, &full_name)?,
1059        full_name,
1060    }))
1061}
1062
1063fn reconcile_pr_creation(
1064    branch: &str,
1065    created: Result<String>,
1066    found: Result<Option<PrRef>>,
1067) -> Result<PrRef> {
1068    match (created, found) {
1069        (_, Ok(Some(pr))) => Ok(pr),
1070        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1071            "PR creation reported success but none was found for {branch}"
1072        ))),
1073        (Err(create), Ok(None)) => Err(spar_err!(
1074            "could not open a PR for {branch}. {}",
1075            create.last_line()
1076        )),
1077        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1078            "PR creation reported success for {branch}, but it could not be verified. {}",
1079            check.last_line()
1080        ))),
1081        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1082            "could not open a PR for {branch}. {} The result could not be verified: {}",
1083            create.last_line(),
1084            check.last_line()
1085        ))),
1086    }
1087}
1088
1089fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
1090    #[derive(Deserialize)]
1091    #[serde(rename_all = "camelCase")]
1092    struct Row {
1093        number: i64,
1094        #[serde(default)]
1095        url: String,
1096        #[serde(default)]
1097        title: String,
1098        base_ref_name: String,
1099    }
1100
1101    let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
1102        spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
1103    })?;
1104    Ok(rows
1105        .into_iter()
1106        .find(|row| row.base_ref_name == base)
1107        .map(|row| PrRef {
1108            number: row.number,
1109            url: row.url,
1110            title: row.title,
1111        }))
1112}
1113
1114/// Sign a comment spar composed, so it can be told from one the same account
1115/// wrote by hand. Idempotent: a body already signed is returned as it is.
1116fn signed(body: &str) -> String {
1117    if body.contains(COMMENT_MARKER) {
1118        return body.to_string();
1119    }
1120    format!("{body}\n\n{COMMENT_MARKER}")
1121}
1122
1123/// Whether a comment body is one spar composed rather than one a person wrote.
1124pub fn is_spar_comment(body: &str) -> bool {
1125    body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
1126}
1127
1128/// What `gh api -i` printed, taken apart.
1129#[derive(Debug, Clone, Default, PartialEq, Eq)]
1130pub struct ApiResponse {
1131    pub status: u16,
1132    /// Header names lowercased, values trimmed.
1133    pub headers: BTreeMap<String, String>,
1134    pub body: String,
1135}
1136
1137/// Split `gh api -i` output into the status, the headers, and the body. None
1138/// when it does not start with a status line, which is what a spawn that
1139/// printed an error looks like.
1140pub fn parse_api_response(stdout: &str) -> Option<ApiResponse> {
1141    let mut lines = stdout.split_inclusive('\n');
1142    let first = lines.next()?.trim_end_matches(['\r', '\n']);
1143    if !first.starts_with("HTTP/") {
1144        return None;
1145    }
1146    let status: u16 = first.split_whitespace().nth(1)?.parse().ok()?;
1147    let mut headers = BTreeMap::new();
1148    let mut body = String::new();
1149    let mut in_body = false;
1150    for line in lines {
1151        if in_body {
1152            body.push_str(line);
1153            continue;
1154        }
1155        let bare = line.trim_end_matches(['\r', '\n']);
1156        if bare.is_empty() {
1157            in_body = true;
1158            continue;
1159        }
1160        if let Some((name, value)) = bare.split_once(':') {
1161            headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
1162        }
1163    }
1164    Some(ApiResponse {
1165        status,
1166        headers,
1167        body,
1168    })
1169}
1170
1171/// The review requests on a pull request's timeline. The last hundred are
1172/// plenty: only the newest one that asks the viewer matters.
1173const REQUESTS_QUERY: &str = "\
1174query($owner: String!, $repo: String!, $number: Int!) {
1175  repository(owner: $owner, name: $repo) {
1176    pullRequest(number: $number) {
1177      timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], last: 100) {
1178        nodes {
1179          ... on ReviewRequestedEvent {
1180            createdAt
1181            requestedReviewer {
1182              ... on User { login }
1183              ... on Team { combinedSlug }
1184            }
1185          }
1186        }
1187      }
1188    }
1189  }
1190}";
1191
1192/// The requests `REQUESTS_QUERY` returned, oldest first. None when the answer
1193/// is not that shape. A request for somebody GitHub no longer knows names
1194/// nobody and is left out, or it would read as a team nobody could check.
1195pub fn parse_review_requests(text: &str) -> Option<Vec<crate::model::ReviewRequested>> {
1196    let v: Value = serde_json::from_str(text).ok()?;
1197    let nodes = v
1198        .pointer("/data/repository/pullRequest/timelineItems/nodes")?
1199        .as_array()?;
1200    let text_at = |node: &Value, path: &str| {
1201        node.pointer(path)
1202            .and_then(Value::as_str)
1203            .unwrap_or_default()
1204            .to_string()
1205    };
1206    Some(
1207        nodes
1208            .iter()
1209            .map(|node| crate::model::ReviewRequested {
1210                at: text_at(node, "/createdAt"),
1211                who: crate::model::Login {
1212                    login: text_at(node, "/requestedReviewer/login"),
1213                    slug: text_at(node, "/requestedReviewer/combinedSlug"),
1214                },
1215            })
1216            .filter(|r| !r.at.is_empty() && !(r.who.login.is_empty() && r.who.slug.is_empty()))
1217            .collect(),
1218    )
1219}
1220
1221/// The pull request a per item state file is about: `pr-12.json`,
1222/// `review-12.json`, `checkin-12.json`. None for anything else, so the shared
1223/// files beside them, `spend.json` and the rest, are never touched.
1224pub(crate) fn state_file_number(name: &str) -> Option<i64> {
1225    let stem = name.strip_suffix(".json")?;
1226    ["pr-", "review-", "checkin-"]
1227        .iter()
1228        .find_map(|prefix| stem.strip_prefix(prefix))
1229        .filter(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
1230        .and_then(|digits| digits.parse().ok())
1231}
1232
1233/// Whether the viewer has already submitted this review: exactly this body, on
1234/// this commit, and still standing. A clean review reads the same on every
1235/// commit, so a body alone would take an approval of an older commit for this
1236/// one; and a dismissed review was withdrawn, so it is not this one either.
1237fn has_viewer_review(reviews: &[Value], viewer: &str, body: &str, commit: &str) -> bool {
1238    reviews.iter().any(|review| {
1239        let field = |name: &str| review.get(name).and_then(Value::as_str).unwrap_or("");
1240        let standing = !matches!(
1241            field("state").to_uppercase().as_str(),
1242            "DISMISSED" | "PENDING"
1243        );
1244        review
1245            .get("user")
1246            .and_then(|u| u.get("login"))
1247            .and_then(Value::as_str)
1248            .is_some_and(|login| crate::comments::same_login(login, viewer))
1249            && field("body") == body
1250            && field("commit_id").eq_ignore_ascii_case(commit)
1251            && standing
1252    })
1253}
1254
1255fn reconcile_review_post(
1256    number: i64,
1257    body: &str,
1258    viewer: &str,
1259    commit: &str,
1260    post_error: crate::error::SparError,
1261    reviews: Result<Vec<Value>>,
1262) -> Result<()> {
1263    match reviews {
1264        Ok(reviews) if has_viewer_review(&reviews, viewer, body, commit) => Ok(()),
1265        Ok(_) => Err(post_error),
1266        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1267            "could not submit a review on #{number}. {} The result could not be verified: {}",
1268            post_error.last_line(),
1269            read_error.last_line()
1270        ))),
1271    }
1272}
1273
1274/// The body of a review that carries line comments, for `gh api --input -`.
1275///
1276/// Every comment is on the new side of the diff, which is the only side a
1277/// finding about the branch is ever about.
1278fn review_payload(commit: &str, event: &str, body: &str, comments: &[InlineComment]) -> Value {
1279    let comments: Vec<Value> = comments
1280        .iter()
1281        .map(|c| {
1282            let mut row = serde_json::json!({
1283                "path": c.path,
1284                "line": c.line,
1285                "side": "RIGHT",
1286                "body": c.body,
1287            });
1288            if let Some(start) = c.start_line {
1289                row["start_line"] = serde_json::json!(start);
1290                row["start_side"] = serde_json::json!("RIGHT");
1291            }
1292            row
1293        })
1294        .collect();
1295    serde_json::json!({
1296        "commit_id": commit,
1297        "event": event,
1298        "body": body,
1299        "comments": comments,
1300    })
1301}
1302
1303/// Whether GitHub looked at a write and refused it, rather than failing to
1304/// answer. A refusal is final, so the same write changed is safe to send; a
1305/// timeout or a gateway error says nothing about whether the first one landed.
1306fn refused(e: &crate::error::SparError) -> bool {
1307    e.to_string().contains("(HTTP 422)")
1308}
1309
1310/// The lines of each file a pull request's diff shows on its new side, as
1311/// first and last line of each hunk. GitHub takes a line comment only on one
1312/// of these, and refuses the whole review over one that is not.
1313pub type Hunks = BTreeMap<String, Vec<(u32, u32)>>;
1314
1315/// Read the hunks out of a unified diff.
1316///
1317/// A hunk's lines are counted off against its header rather than recognised by
1318/// their first character, so an added line that itself begins `+++ ` or `@@ `
1319/// is content and not the start of something. A path git had to quote is left
1320/// out: what is between the quotes is escaped, not a path GitHub would match.
1321fn new_side_hunks(diff: &str) -> Hunks {
1322    let mut out = Hunks::new();
1323    let mut path: Option<String> = None;
1324    let (mut old_left, mut new_left) = (0u32, 0u32);
1325    for line in diff.lines() {
1326        if old_left > 0 || new_left > 0 {
1327            match line.as_bytes().first() {
1328                Some(b'+') => new_left = new_left.saturating_sub(1),
1329                Some(b'-') => old_left = old_left.saturating_sub(1),
1330                Some(b'\\') => {}
1331                _ => {
1332                    old_left = old_left.saturating_sub(1);
1333                    new_left = new_left.saturating_sub(1);
1334                }
1335            }
1336            continue;
1337        }
1338        if line.starts_with("diff --git ") {
1339            path = None;
1340        } else if let Some(rest) = line.strip_prefix("+++ ") {
1341            // git ends the line with a tab when the path has a space in it,
1342            // so that a patch tool can tell where the name stops.
1343            let rest = if rest.contains(' ') {
1344                rest.strip_suffix('\t').unwrap_or(rest)
1345            } else {
1346                rest
1347            };
1348            path = rest
1349                .strip_prefix("b/")
1350                .filter(|p| !p.is_empty())
1351                .map(str::to_string);
1352        } else if let Some(((_, old_len), (start, len))) = hunk_header(line) {
1353            old_left = old_len;
1354            new_left = len;
1355            if let (Some(path), true) = (&path, len > 0 && start > 0) {
1356                out.entry(path.clone())
1357                    .or_default()
1358                    .push((start, start + len - 1));
1359            }
1360        }
1361    }
1362    out
1363}
1364
1365/// `@@ -a,b +c,d @@`, as `((a, b), (c, d))`. A count left out is one.
1366fn hunk_header(line: &str) -> Option<((u32, u32), (u32, u32))> {
1367    let ranges = line.strip_prefix("@@ -")?.split_once(" @@")?.0;
1368    let (old, new) = ranges.split_once(" +")?;
1369    let range = |s: &str| -> Option<(u32, u32)> {
1370        match s.split_once(',') {
1371            Some((start, len)) => Some((start.parse().ok()?, len.parse().ok()?)),
1372            None => Some((s.parse().ok()?, 1)),
1373        }
1374    };
1375    Some((range(old)?, range(new)?))
1376}
1377
1378fn has_exact_comment(comments: &[Value], body: &str) -> bool {
1379    comments.iter().any(|comment| {
1380        comment
1381            .get("body")
1382            .and_then(Value::as_str)
1383            .is_some_and(|seen| seen == body)
1384    })
1385}
1386
1387fn reconcile_comment_post(
1388    number: i64,
1389    body: &str,
1390    post_error: crate::error::SparError,
1391    comments: Result<Vec<Value>>,
1392) -> Result<()> {
1393    match comments {
1394        Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
1395        Ok(_) => Err(post_error),
1396        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1397            "could not comment on #{number}. {} The result could not be verified: {}",
1398            post_error.last_line(),
1399            read_error.last_line()
1400        ))),
1401    }
1402}
1403
1404fn reconcile_issue_edit(
1405    number: i64,
1406    wanted: &str,
1407    edit_error: crate::error::SparError,
1408    observed: Result<String>,
1409) -> Result<()> {
1410    match observed {
1411        Ok(body) if body == wanted => Ok(()),
1412        Ok(_) => Err(spar_err!(
1413            "could not rewrite the body of #{number}. {}",
1414            edit_error.last_line()
1415        )),
1416        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
1417            "could not rewrite the body of #{number}. {} The result could not be verified: {}",
1418            edit_error.last_line(),
1419            read_error.last_line()
1420        ))),
1421    }
1422}
1423
1424fn issue_url_has_number(url: &str) -> bool {
1425    url.trim()
1426        .rsplit('/')
1427        .next()
1428        .and_then(|tail| tail.parse::<i64>().ok())
1429        .is_some_and(|number| number > 0)
1430}
1431
1432fn reconcile_issue_creation(
1433    title: &str,
1434    created: Result<String>,
1435    found: Result<Option<ExistingIssue>>,
1436) -> Result<String> {
1437    match (created, found) {
1438        (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
1439        (_, Ok(Some(issue))) => Ok(issue.url),
1440        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
1441            "issue creation reported success but no matching issue was found for {title:?}"
1442        ))),
1443        (Err(create), Ok(None)) => Err(spar_err!(
1444            "could not file issue {title:?}. {}",
1445            create.last_line()
1446        )),
1447        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1448            "issue creation reported success for {title:?}, but it could not be verified. {}",
1449            check.last_line()
1450        ))),
1451        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
1452            "could not file issue {title:?}. {} The result could not be verified: {}",
1453            create.last_line(),
1454            check.last_line()
1455        ))),
1456    }
1457}
1458
1459fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
1460    if output.trim().is_empty() {
1461        return Ok(None);
1462    }
1463    for line in output.lines() {
1464        let mut fields = line.split_whitespace();
1465        let oid = fields.next().unwrap_or_default();
1466        let name = fields.next().unwrap_or_default();
1467        if name == remote_ref && !oid.is_empty() {
1468            return Ok(Some(oid.to_string()));
1469        }
1470    }
1471    Err(spar_err!(
1472        "origin returned an unexpected ref listing for {remote_ref}"
1473    ))
1474}
1475
1476fn reconcile_failed_split_push(
1477    branch: &str,
1478    push_error: crate::error::SparError,
1479    local: Result<String>,
1480    remote: Result<String>,
1481) -> std::result::Result<(), SplitPushError> {
1482    let remote_ref = format!("refs/heads/{branch}");
1483    match (local, remote) {
1484        (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
1485            Ok(Some(oid)) if oid == local.trim() => Ok(()),
1486            Ok(_) => Err(SplitPushError::new(
1487                format!(
1488                    "could not create origin/{branch}. {} The remote branch is absent or points \
1489                     somewhere else. Nothing was overwritten.",
1490                    push_error.last_line()
1491                ),
1492                false,
1493            )),
1494            Err(check) => Err(SplitPushError::new(
1495                format!(
1496                    "could not confirm whether origin/{branch} was created. {} The remote result \
1497                     could not be verified: {}",
1498                    push_error.last_line(),
1499                    check.last_line()
1500                ),
1501                true,
1502            )),
1503        },
1504        (local, remote) => {
1505            let check = match (local, remote) {
1506                (Err(local), Err(remote)) => format!(
1507                    "the local commit could not be read: {}; origin could not be read: {}",
1508                    local.last_line(),
1509                    remote.last_line()
1510                ),
1511                (Err(local), _) => {
1512                    format!("the local commit could not be read: {}", local.last_line())
1513                }
1514                (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
1515                _ => unreachable!(),
1516            };
1517            Err(SplitPushError::new(
1518                format!(
1519                    "could not confirm whether origin/{branch} was created. {} The result could \
1520                     not be verified because {check}",
1521                    push_error.last_line()
1522                ),
1523                true,
1524            ))
1525        }
1526    }
1527}
1528
1529impl Repo {
1530    pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
1531        let root =
1532            std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
1533        // A linked worktree has a `.git` file rather than a directory, and a
1534        // bare-ish layout can have neither, so ask git instead of guessing.
1535        let inside = proc::run_str(
1536            &["git", "rev-parse", "--is-inside-work-tree"],
1537            &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
1538        )
1539        .unwrap_or_default();
1540        if inside.trim() != "true" {
1541            bail!("not a git repository: {}", root.display());
1542        }
1543        let repo = Self {
1544            root,
1545            style: cfg.style.clone(),
1546            branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
1547            state_store: cfg.loop_cfg.state_store,
1548            followups: cfg.loop_cfg.followups,
1549            drafts: cfg.loop_cfg.drafts,
1550            fork_when_denied: cfg.loop_cfg.fork_when_denied,
1551            setup: cfg.loop_cfg.setup.clone(),
1552            fork: OnceLock::new(),
1553            viewer: OnceLock::new(),
1554            checkpoints: Mutex::new(BTreeMap::new()),
1555            writes: WriteStats::default(),
1556        };
1557        repo.self_exclude();
1558        Ok(repo)
1559    }
1560
1561    /// Keep spar's own scratch directories out of the target repo's
1562    /// `git status`.
1563    ///
1564    /// Written to `.git/info/exclude`, never to a tracked `.gitignore`: this is
1565    /// somebody else's repository and spar has no business committing to it.
1566    /// Best effort and silent on failure, because a read-only git directory is
1567    /// not a reason to abandon a run.
1568    fn self_exclude(&self) {
1569        let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1570        let git_dir = git_dir.trim();
1571        if git_dir.is_empty() {
1572            return;
1573        }
1574        let path = Path::new(git_dir).join("info").join("exclude");
1575        let existing = std::fs::read_to_string(&path).unwrap_or_default();
1576
1577        // The marker too. It is written into whichever directory the call ran
1578        // in, which for a shared checkout is the repository root, and an
1579        // untracked file there is one `git add -A` away from being committed.
1580        // The error names its path, which is how a person is meant to find it.
1581        let wanted = [
1582            format!("/{WORKTREE_DIR}/"),
1583            format!("/{STATE_DIR}/"),
1584            format!("{RECOVERY_MARKER_PREFIX}*"),
1585        ];
1586        let missing: Vec<&String> = wanted
1587            .iter()
1588            .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
1589            .collect();
1590        if missing.is_empty() {
1591            return;
1592        }
1593
1594        use std::io::Write;
1595        if let Some(parent) = path.parent() {
1596            let _ = std::fs::create_dir_all(parent);
1597        }
1598        let mut block = String::new();
1599        if !existing.is_empty() && !existing.ends_with('\n') {
1600            block.push('\n');
1601        }
1602        block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
1603        for line in missing {
1604            block.push_str(line);
1605            block.push('\n');
1606        }
1607        if let Ok(mut file) = std::fs::OpenOptions::new()
1608            .create(true)
1609            .append(true)
1610            .open(&path)
1611        {
1612            let _ = file.write_all(block.as_bytes());
1613        }
1614    }
1615
1616    pub fn root(&self) -> &Path {
1617        &self.root
1618    }
1619
1620    pub(crate) fn write_summary(&self) -> WriteSummary {
1621        WriteSummary {
1622            attempted: self.writes.attempted.load(Ordering::Relaxed),
1623            failed: self.writes.failed.load(Ordering::Relaxed),
1624        }
1625    }
1626
1627    pub(crate) fn record_write<T, E>(
1628        &self,
1629        result: std::result::Result<T, E>,
1630    ) -> std::result::Result<T, E> {
1631        self.record_write_outcome(result.is_err());
1632        result
1633    }
1634
1635    pub(crate) fn record_failed_write<T, E>(
1636        &self,
1637        result: std::result::Result<T, E>,
1638    ) -> std::result::Result<T, E> {
1639        if result.is_err() {
1640            self.record_write_outcome(true);
1641        }
1642        result
1643    }
1644
1645    fn record_write_outcome(&self, failed: bool) {
1646        self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1647        if failed {
1648            self.writes.failed.fetch_add(1, Ordering::Relaxed);
1649        }
1650    }
1651
1652    // -- gates ------------------------------------------------------------
1653
1654    /// Scrub, then verify. A leak here reaches GitHub, so it is a hard error
1655    /// rather than a warning: silent partial compliance is how a style rule
1656    /// erodes over a long run.
1657    pub fn clean(&self, text: &str) -> Result<String> {
1658        let out = style::scrub(text, &self.style);
1659        let bad = style::violations(&out, &self.style);
1660        if !bad.is_empty() {
1661            bail!(
1662                "style gate could not clean text ({}): {}",
1663                bad.join(", "),
1664                style::clip(&out, 300)
1665            );
1666        }
1667        Ok(out)
1668    }
1669
1670    /// Clean, and hold to a length budget. For anything a model wrote.
1671    pub fn clean_body(&self, text: &str) -> Result<String> {
1672        self.clean(&style::body(text, &self.style))
1673    }
1674
1675    /// The same, with an issue's far larger budget and its exemption for code.
1676    pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1677        self.clean(&style::issue_body(text, &self.style))
1678    }
1679
1680    /// The single transform every outbound title goes through.
1681    ///
1682    /// Scrub first, clip second, and never the other way round. Clipping first
1683    /// lets the scrub lengthen the result past the budget (an em dash becomes
1684    /// two characters), so a second pass would clip again and produce a
1685    /// different string. That broke follow-up deduplication silently: the
1686    /// lookup searched for one title while GitHub had stored another, no match
1687    /// was ever found, and a fresh duplicate issue was filed every review
1688    /// round. Doing it in this order makes the transform idempotent, which the
1689    /// tests assert.
1690    pub fn clean_title(&self, text: &str) -> Result<String> {
1691        Ok(style::title(&self.clean(text)?, &self.style))
1692    }
1693
1694    pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1695        let title = self.record_failed_write(self.clean_title(text))?;
1696        if title.trim().is_empty() {
1697            return self.record_failed_write(Err(spar_err!(
1698                "nothing left of the title after cleaning it"
1699            )));
1700        }
1701        Ok(title)
1702    }
1703
1704    pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1705        if self.followups == Followups::Issues {
1706            self.clean_nonempty_title_for_write(text)
1707        } else {
1708            self.clean_title(text)
1709        }
1710    }
1711
1712    // -- git --------------------------------------------------------------
1713
1714    fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1715        ExecOpts::new()
1716            .cwd(cwd.unwrap_or(&self.root))
1717            .check(check)
1718            .timeout_secs(600)
1719    }
1720
1721    pub fn git(&self, args: &[&str]) -> Result<String> {
1722        self.git_at(None, args)
1723    }
1724
1725    pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1726        let argv = git_without_maintenance_argv(args);
1727        proc::run(&argv, &self.git_opts(cwd, true))
1728    }
1729
1730    /// Run a parent-side Git operation without inherited background helpers.
1731    ///
1732    /// Editing calls are untrusted. Once one returns, status, staging, and
1733    /// committing happen in this process, so an inherited fsmonitor or automatic
1734    /// maintenance command must not become a way to execute outside its sandbox.
1735    fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1736        let argv = git_without_automation_argv(args);
1737        proc::run(
1738            &argv,
1739            &self.git_opts(Some(cwd), true).stop_descendants(true),
1740        )
1741    }
1742
1743    fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1744        let argv = git_without_automation_argv(args);
1745        proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1746            .map(|output| output.ok())
1747    }
1748
1749    /// Run git, tolerating failure. Returns whatever landed on stdout.
1750    pub fn git_try(&self, args: &[&str]) -> String {
1751        self.git_try_at(None, args)
1752    }
1753
1754    pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1755        let argv = git_without_maintenance_argv(args);
1756        proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1757    }
1758
1759    /// The base branch the remote actually points at, rather than assuming
1760    /// `main`. Falls back to the configured value when there is no origin.
1761    pub fn default_branch(&self, configured: &str) -> String {
1762        let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1763        match refname.trim().rsplit('/').next() {
1764            Some(name) if !name.is_empty() => name.to_string(),
1765            _ => configured.to_string(),
1766        }
1767    }
1768
1769    // -- branch naming and ownership --------------------------------------
1770    //
1771    // Branch names default to `issue-N`, which is exactly what a person would
1772    // name a branch by hand. Ownership therefore cannot be inferred from the
1773    // name, so every branch spar creates is recorded and cleanup only ever
1774    // touches what is in that record.
1775
1776    pub fn branch_for_issue(&self, issue: i64) -> String {
1777        format!("{}issue-{issue}", self.branch_prefix)
1778    }
1779
1780    pub fn branch_for_pr(&self, number: i64) -> String {
1781        format!("{}pr-{number}", self.branch_prefix)
1782    }
1783
1784    /// One part of a split, numbered from 1 within its parent.
1785    ///
1786    /// Its own namespace rather than `issue-N`, because the parts of a split
1787    /// pull request have no issue of their own and would otherwise collide with
1788    /// the branch of the issue that happens to share the parent's number.
1789    ///
1790    /// The name a part is tried on first. `worktree_for_split` may end up on a
1791    /// suffixed one, because this name is not free forever.
1792    pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1793        format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1794    }
1795
1796    fn ledger_path(&self) -> PathBuf {
1797        self.root.join(STATE_DIR).join("branches.json")
1798    }
1799
1800    pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1801        std::fs::read_to_string(self.ledger_path())
1802            .ok()
1803            .and_then(|text| serde_json::from_str(&text).ok())
1804            .unwrap_or_default()
1805    }
1806
1807    pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1808        let mut data = self.known_branches();
1809        data.insert(
1810            branch.to_string(),
1811            BranchRecord {
1812                kind: kind.to_string(),
1813                number,
1814            },
1815        );
1816        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1817            logdim!("could not record branch {branch}: {e}");
1818        }
1819    }
1820
1821    pub fn forget_branch(&self, branch: &str) {
1822        let mut data = self.known_branches();
1823        if data.remove(branch).is_none() {
1824            return;
1825        }
1826        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1827            logdim!("could not update the branch record: {e}");
1828        }
1829    }
1830
1831    // -- worktrees --------------------------------------------------------
1832
1833    fn worktree_path(&self, name: &str) -> PathBuf {
1834        self.root.join(WORKTREE_DIR).join(name)
1835    }
1836
1837    /// Run `[loop] setup` in a worktree spar has just created.
1838    ///
1839    /// Before anything else looks at the tree, so the baseline an editing call
1840    /// is measured against already holds what setup wrote, and a setup that
1841    /// fails costs no agent call. The worktree is kept as it is: what an
1842    /// install writes is build output, which the next run rebuilds over.
1843    fn set_up_worktree(&self, path: &Path, label: &str) -> Result<()> {
1844        let argv = self.setup.argv();
1845        if argv.is_empty() {
1846            return Ok(());
1847        }
1848        let command = self.setup.describe();
1849        logdim!("setting up the {label}: {command}");
1850        let out = proc::exec(
1851            &argv,
1852            &ExecOpts::new()
1853                .cwd(path)
1854                .check(false)
1855                .timeout_secs(SETUP_TIMEOUT_SECS)
1856                .stop_descendants(true),
1857        )
1858        .map_err(|e| spar_err!("could not run the setup `{command}` for the {label}: {e}"))?;
1859        if !out.ok() {
1860            let text = format!("{}\n{}", out.stdout.trim_end(), out.stderr.trim_end());
1861            bail!(
1862                "the setup `{command}` failed in the {label} at {}, so no agent was asked to \
1863                 work in it.\n{}",
1864                path.display(),
1865                style::clip(text.trim(), 4000)
1866            );
1867        }
1868        Ok(())
1869    }
1870
1871    /// Isolate an issue so a failed run cannot poison the next one's base.
1872    pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1873        let branch = self.branch_for_issue(issue);
1874        let path = self.worktree_path(&format!("issue-{issue}"));
1875
1876        self.refuse_issue_branch_rebuild(issue, base)?;
1877        self.refuse_dirty_worktree(
1878            &path,
1879            &format!("worktree for issue #{issue}"),
1880            Disposable::BuildOutput,
1881        )?;
1882
1883        if !self.branch_deletion_is_safe(&branch)? {
1884            bail!(
1885                "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1886                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1887                 before retrying."
1888            );
1889        }
1890
1891        if !self.remove_worktree_at(&path)? {
1892            bail!(
1893                "the existing worktree for issue #{issue} could not be removed safely. Its \
1894                 branch was kept."
1895            );
1896        }
1897        if !self.delete_branch_if_safe(&branch)? {
1898            bail!(
1899                "the existing branch {branch} changed or remained checked out while its \
1900                 worktree was being rebuilt. It was kept."
1901            );
1902        }
1903
1904        if let Some(parent) = path.parent() {
1905            std::fs::create_dir_all(parent)
1906                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1907        }
1908
1909        let path_str = path.display().to_string();
1910        let remote_start = format!("origin/{base}");
1911        let created = self
1912            .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1913            .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1914
1915        // Recorded on both paths: an unrecorded branch is one cleanup will
1916        // never remove, and the fallback creates a branch just the same.
1917        created.map_err(|e| {
1918            spar_err!(
1919                "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1920                 and does `origin` exist?",
1921                e.last_line()
1922            )
1923        })?;
1924        self.record_branch(&branch, "issue", issue);
1925        self.set_up_worktree(&path, &format!("worktree for issue #{issue}"))?;
1926        Ok((path, branch))
1927    }
1928
1929    /// Refuse to reset the local or remote branch assigned to an issue when it
1930    /// carries work that no pull request preserves.
1931    ///
1932    /// Both linked-worktree mode and shared-checkout mode rebuild the same
1933    /// branch name. Keeping the guard here prevents either path from silently
1934    /// replacing recovery commits left by an earlier run.
1935    pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1936        let branch = self.branch_for_issue(issue);
1937        let base_remote_ref = format!("refs/heads/{base}");
1938        let base_tracking_ref = format!("refs/remotes/origin/{base}");
1939        let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1940        self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1941            .map_err(|e| {
1942                spar_err!(
1943                    "could not refresh origin/{base} before checking issue #{issue}: {}",
1944                    e.last_line()
1945                )
1946            })?;
1947
1948        if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
1949            let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
1950            if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
1951                bail!(
1952                    "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
1953                     pull request accounts for them. Rebuilding it would force push over that \
1954                     work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
1955                     it, or delete it with `git push origin --delete {branch}` if the remote \
1956                     branch is no longer needed."
1957                );
1958            }
1959        }
1960
1961        // The same question of the fork a refused push would go to, asked here
1962        // rather than after an agent has spent its call implementing.
1963        if self.fork_when_denied && self.origin_refuses_push() {
1964            match self.existing_fork() {
1965                Ok(Some(fork)) => {
1966                    self.fork_branch_lease(&fork, &branch, base)?;
1967                }
1968                Ok(None) => {}
1969                Err(e) => logdim!("{e} It is checked again before anything is pushed to it."),
1970            }
1971        }
1972
1973        let local_ref = format!("refs/heads/{branch}");
1974        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1975            let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
1976            let recorded_pr = self
1977                .known_branches()
1978                .get(&branch)
1979                .is_some_and(|record| record.kind == "pr");
1980            let preserved = ahead == 0
1981                || if recorded_pr {
1982                    self.local_branch_is_preserved(&branch)?
1983                } else {
1984                    self.pull_request_holds(&branch, &local_ref, base)
1985                };
1986            if !preserved {
1987                let listed = self
1988                    .commit_lines(&self.root, &local_ref, base)
1989                    .iter()
1990                    .map(|line| format!("  {line}"))
1991                    .collect::<Vec<_>>()
1992                    .join("\n");
1993                bail!(
1994                    "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
1995                     no pull request preserves them. Rebuilding it would delete the only copy.\n\
1996                     {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
1997                     it, or delete it with `git branch -D {branch}` if it is stale."
1998                );
1999            }
2000        }
2001        Ok(())
2002    }
2003
2004    fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
2005        let live_ref = format!("refs/heads/{branch}");
2006        let tracking_ref = format!("refs/remotes/origin/{branch}");
2007        let listed = self
2008            .git(&["ls-remote", "--heads", "origin", &live_ref])
2009            .map_err(|e| {
2010                spar_err!(
2011                    "could not verify whether origin/{branch} still exists: {}",
2012                    e.last_line()
2013                )
2014            })?;
2015
2016        if remote_head_oid(&listed, &live_ref)?.is_some() {
2017            let refspec = format!("+{live_ref}:{tracking_ref}");
2018            self.git(&["fetch", "--no-tags", "origin", &refspec])
2019                .map_err(|e| {
2020                    spar_err!(
2021                        "origin/{branch} exists but its tracking ref could not be refreshed: {}",
2022                        e.last_line()
2023                    )
2024                })?;
2025            if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2026                bail!("origin/{branch} was fetched but its tracking ref is missing");
2027            }
2028            return Ok(Some(tracking_ref));
2029        }
2030
2031        if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2032            return Ok(None);
2033        }
2034        let expected = self
2035            .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
2036            .trim()
2037            .to_string();
2038        self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
2039            .map_err(|e| {
2040                spar_err!(
2041                    "could not discard stale origin/{branch} tracking ref safely: {}",
2042                    e.last_line()
2043                )
2044            })?;
2045        if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
2046            bail!(
2047                "origin/{branch} changed while its stale tracking ref was being removed. It was \
2048                 kept."
2049            );
2050        }
2051        Ok(None)
2052    }
2053
2054    /// Whether a pull request from `branch` already holds every commit `refname`
2055    /// has beyond `base`.
2056    ///
2057    /// GitHub serves `refs/pull/N/head` for as long as the repository lives, so
2058    /// commits that reached a pull request outlive the branch they were pushed
2059    /// from. A matching branch name does not establish that on its own: an
2060    /// issue worked twice reuses the name, and the merged pull request from the
2061    /// first round says nothing about where the second round's commits are.
2062    fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
2063        self.prs_for_branch(branch)
2064            .iter()
2065            .any(|pr| self.pr_head_holds(pr.number, refname, base))
2066    }
2067
2068    fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
2069        let head = format!("refs/spar/pr-head/{number}");
2070        let refspec = format!("+refs/pull/{number}/head:{head}");
2071        if self.git(&["fetch", "origin", &refspec]).is_err() {
2072            return false;
2073        }
2074        let held = self.commits_held_by(refname, base, &head);
2075        self.git_try(&["update-ref", "-d", &head]);
2076        held
2077    }
2078
2079    pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
2080        let argv = vec![
2081            "git".to_string(),
2082            "merge-base".to_string(),
2083            "--is-ancestor".to_string(),
2084            older.to_string(),
2085            newer.to_string(),
2086        ];
2087        let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
2088        match out.code {
2089            0 => Ok(true),
2090            1 => Ok(false),
2091            _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
2092        }
2093    }
2094
2095    fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
2096        let head = format!("refs/spar/pr-head/{number}");
2097        let refspec = format!("+refs/pull/{number}/head:{head}");
2098        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2099            spar_err!(
2100                "could not verify the immutable head of PR #{number}: {}",
2101                e.last_line()
2102            )
2103        })?;
2104        let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
2105        self.git_try(&["update-ref", "-d", &head]);
2106        held
2107    }
2108
2109    fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
2110        let text = self.gh(&[
2111            "pr",
2112            "list",
2113            "--head",
2114            branch,
2115            "--state",
2116            "all",
2117            "--json",
2118            "number,url,title",
2119        ])?;
2120        serde_json::from_str(text.trim())
2121            .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
2122    }
2123
2124    fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
2125        let branch_ref = format!("refs/heads/{branch}");
2126        if record.kind == "pr" {
2127            return self.pr_head_contains_checked(record.number, &branch_ref);
2128        }
2129        let prs = self.branch_prs_checked(branch)?;
2130        if prs.is_empty() {
2131            return Ok(false);
2132        }
2133        for pr in prs {
2134            if self.pr_head_contains_checked(pr.number, &branch_ref)? {
2135                return Ok(true);
2136            }
2137        }
2138        Ok(false)
2139    }
2140
2141    fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
2142        let local_ref = format!("refs/heads/{branch}");
2143        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2144            return Ok(true);
2145        }
2146        let oid = self
2147            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2148            .trim()
2149            .to_string();
2150        let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
2151        if !durable_tip {
2152            let remote_ref = format!("refs/heads/{branch}");
2153            let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
2154            durable_tip = remote.lines().any(|line| {
2155                line.split_whitespace()
2156                    .next()
2157                    .is_some_and(|remote_oid| remote_oid == oid)
2158            });
2159        }
2160        if !durable_tip {
2161            if let Some(record) = self.known_branches().get(branch) {
2162                durable_tip = self.branch_is_preserved_checked(branch, record)?;
2163            }
2164        }
2165        if !durable_tip {
2166            return Ok(false);
2167        }
2168        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2169    }
2170
2171    /// Delete a branch only if its exact current tip and reflog are still safe.
2172    /// The expected old value makes a concurrent ref update fail instead of
2173    /// deleting work that appeared after the preservation check.
2174    fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
2175        let local_ref = format!("refs/heads/{branch}");
2176        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2177            return Ok(true);
2178        }
2179        let expected = self
2180            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2181            .trim()
2182            .to_string();
2183        if !self.branch_deletion_is_safe(branch)? {
2184            return Ok(false);
2185        }
2186        let checked_out = self
2187            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
2188            .lines()
2189            .any(|line| line == format!("branch {local_ref}"));
2190        if checked_out {
2191            return Ok(false);
2192        }
2193        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2194        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2195    }
2196
2197    fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
2198        let local_ref = review_ref(number);
2199        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2200            return Ok(true);
2201        }
2202        let oid = self
2203            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2204            .trim()
2205            .to_string();
2206        if !self.pr_head_contains_checked(number, &local_ref)? {
2207            return Ok(false);
2208        }
2209        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
2210    }
2211
2212    fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
2213        let local_ref = review_ref(number);
2214        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2215            return Ok(true);
2216        }
2217        let expected = self
2218            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2219            .trim()
2220            .to_string();
2221        if !self.review_ref_deletion_is_safe(number)? {
2222            return Ok(false);
2223        }
2224        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
2225        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
2226    }
2227
2228    /// Whether `other` already contains every commit `branch` has beyond
2229    /// `base`. False when either ref fails to resolve, so a ref that is not
2230    /// there cannot vouch for anything.
2231    pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
2232        let range = format!("{}..{branch}", self.base_ref(&self.root, base));
2233        self.git_try(&["rev-list", "--count", &range, "--not", other])
2234            .trim()
2235            == "0"
2236    }
2237
2238    pub fn worktree_remove(&self, issue: i64) -> bool {
2239        let path = self.worktree_path(&format!("issue-{issue}"));
2240        match self.remove_worktree_at(&path) {
2241            Ok(removed) => removed,
2242            Err(error) => {
2243                logdim!(
2244                    "kept {} because removal did not reach a confirmed quiet point: {}",
2245                    path.display(),
2246                    error.last_line()
2247                );
2248                false
2249            }
2250        }
2251    }
2252
2253    /// Verify both the common Git directory and the worktree top level.
2254    ///
2255    /// A stale worktree entry is not ownership proof. An unrelated repository
2256    /// can later occupy the same path and must survive cleanup.
2257    fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
2258        let wanted = std::fs::canonicalize(path)
2259            .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
2260        // Every SPAR worktree path is built from the canonical repository root.
2261        // A different canonical path therefore means the final component or
2262        // one of its parents is a symlink. Passing that alias to `git worktree
2263        // remove` can delete the worktree at its real target.
2264        if wanted != path {
2265            return Ok(false);
2266        }
2267        let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
2268            let raw = PathBuf::from(value.trim());
2269            let joined = if raw.is_absolute() {
2270                raw
2271            } else {
2272                cwd.join(raw)
2273            };
2274            std::fs::canonicalize(&joined)
2275                .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
2276        };
2277        let expected =
2278            self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
2279        let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
2280        let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
2281        let expected = resolve(&self.root, &expected)?;
2282        let actual = resolve(path, &actual)?;
2283        let top = resolve(path, &top)?;
2284        Ok(expected == actual && top == wanted)
2285    }
2286
2287    /// Remove only a worktree that belongs to this repository.
2288    ///
2289    /// The path sits under a predictable directory, but that does not establish
2290    /// ownership. A clean independent repository at the same path must survive
2291    /// even when `git worktree remove` rejects it.
2292    fn remove_worktree_at_with_force(
2293        &self,
2294        path: &Path,
2295        force: bool,
2296        disposable: Disposable,
2297    ) -> Result<bool> {
2298        let existed = path.exists();
2299        if path.exists() {
2300            match self.worktree_belongs_to_repo(path) {
2301                Ok(true) => {}
2302                Ok(false) => {
2303                    logdim!(
2304                        "kept {} because it is not a worktree owned by this repository",
2305                        path.display()
2306                    );
2307                    return Ok(false);
2308                }
2309                Err(e) => {
2310                    logdim!(
2311                        "kept {} because its worktree ownership could not be verified: {}",
2312                        path.display(),
2313                        e.last_line()
2314                    );
2315                    return Ok(false);
2316                }
2317            }
2318            if !force {
2319                match self.has_recoverable_work(path, disposable) {
2320                    Ok(true) => {
2321                        logdim!(
2322                            "kept {} because it contains recoverable files or repository state",
2323                            path.display()
2324                        );
2325                        return Ok(false);
2326                    }
2327                    Err(e) => {
2328                        logdim!(
2329                            "kept {} because its recoverable state could not be checked: {}",
2330                            path.display(),
2331                            e.last_line()
2332                        );
2333                        return Ok(false);
2334                    }
2335                    Ok(false) => {}
2336                }
2337            }
2338        }
2339        // Git refuses to remove a worktree holding any untracked file, which is
2340        // the same question SPAR has just answered in more detail: under
2341        // `ReviewOutput` the checks above have already established that nothing
2342        // there is worth keeping. `--force` here is that answer being carried
2343        // out, not the check being skipped, which is what `force` means.
2344        let path_str = path.display().to_string();
2345        let command_ok = if force || disposable == Disposable::ReviewOutput {
2346            self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
2347        } else {
2348            self.git_try_without_automation(&["worktree", "remove", &path_str])?
2349        };
2350        Ok((command_ok || !existed) && !path.exists())
2351    }
2352
2353    fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
2354        self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
2355    }
2356
2357    /// Remove a review checkout that is about to be rebuilt from origin.
2358    fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
2359        self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
2360    }
2361
2362    /// Force removal is reserved for the explicit `clean --all` path.
2363    fn remove_worktree_at_force(&self, path: &Path) -> bool {
2364        match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
2365            Ok(removed) => removed,
2366            Err(error) => {
2367                logdim!(
2368                    "kept {} because removal did not reach a confirmed quiet point: {}",
2369                    path.display(),
2370                    error.last_line()
2371                );
2372                false
2373            }
2374        }
2375    }
2376
2377    /// Remove a worktree only after a caller has verified it is unchanged.
2378    ///
2379    /// There is deliberately no force fallback, so Git can still refuse a
2380    /// removal if tracked or non-ignored work appears after the final check.
2381    fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
2382        if path.exists() && !self.worktree_belongs_to_repo(path)? {
2383            bail!(
2384                "{} is not a worktree owned by this repository, so it was kept",
2385                path.display()
2386            );
2387        }
2388        if path.exists() && self.has_recoverable_work(path, disposable)? {
2389            bail!(
2390                "the verified worktree at {} contains recoverable files or repository state. It \
2391                 was kept.",
2392                path.display()
2393            );
2394        }
2395        let path_str = path.display().to_string();
2396        self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
2397            .map_err(|e| {
2398                e.with_message(format!(
2399                    "could not remove the verified worktree at {}: {}. It was kept.",
2400                    path.display(),
2401                    e.last_line()
2402                ))
2403            })?;
2404        Ok(!path.exists())
2405    }
2406
2407    fn refuse_dirty_worktree(
2408        &self,
2409        path: &Path,
2410        label: &str,
2411        disposable: Disposable,
2412    ) -> Result<()> {
2413        if !path.is_dir() {
2414            return Ok(());
2415        }
2416        let has_files = std::fs::read_dir(path)
2417            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
2418            .next()
2419            .is_some();
2420        let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
2421            spar_err!(
2422                "could not verify whether the existing {label} at {} belongs to this repository, \
2423                 so it was kept: {}",
2424                path.display(),
2425                e.last_line()
2426            )
2427        })?;
2428        if !owned {
2429            if has_files {
2430                bail!(
2431                    "the existing {label} at {} is not a worktree owned by \
2432                     this repository. Refusing to remove it.",
2433                    path.display()
2434                );
2435            }
2436            return Ok(());
2437        }
2438        if !path.join(".git").exists() {
2439            if has_files {
2440                bail!(
2441                    "the existing {label} at {} is not a readable Git worktree and is not empty. \
2442                     Refusing to remove it.",
2443                    path.display()
2444                );
2445            }
2446            return Ok(());
2447        }
2448        let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
2449            spar_err!(
2450                "could not verify whether the existing {label} at {} is clean, so it was kept: \
2451                 {}",
2452                path.display(),
2453                e.last_line()
2454            )
2455        })?;
2456        if dirty {
2457            bail!(
2458                "the existing {label} contains uncommitted changes or ignored files at {}. \
2459                 Rebuilding it would delete those files.\nCommit or recover them before running this \
2460                 command again, or use `spar clean --all` if they are not needed.",
2461                path.display()
2462            );
2463        }
2464        Ok(())
2465    }
2466
2467    /// Check an existing PR branch out into an isolated worktree, from
2468    /// wherever `target` says the branch is published.
2469    pub fn worktree_for_pr(&self, pr: &PrView, target: &PushTarget) -> Result<(PathBuf, String)> {
2470        let head = pr.head_ref_name.clone();
2471        if head.trim().is_empty() {
2472            bail!("PR #{} has no head branch to check out", pr.number);
2473        }
2474        let path = self.worktree_path(&format!("pr-{}", pr.number));
2475        let local = self.branch_for_pr(pr.number);
2476
2477        let unfetched = |e: crate::error::SparError| {
2478            spar_err!(
2479                "could not fetch the branch behind PR #{}: {}",
2480                pr.number,
2481                e.last_line()
2482            )
2483        };
2484        // What the worktree starts from, the ref behind it, and what to call
2485        // it when saying so.
2486        let (start, start_ref, shown) = match target {
2487            PushTarget::Origin => {
2488                self.git(&["fetch", "origin", &head]).map_err(unfetched)?;
2489                let start = format!("origin/{head}");
2490                (start.clone(), format!("refs/remotes/origin/{head}"), start)
2491            }
2492            // A fork has no remote here to track it, so its branch is
2493            // fetched into a ref of spar's own.
2494            PushTarget::Fork(fork) => {
2495                let start_ref = format!("refs/spar/fork/{head}");
2496                let refspec = format!("+refs/heads/{head}:{start_ref}");
2497                self.git(&["fetch", "--no-tags", &fork.url, &refspec])
2498                    .map_err(unfetched)?;
2499                (start_ref.clone(), start_ref, target.describe(&head))
2500            }
2501        };
2502        let local_ref = format!("refs/heads/{local}");
2503        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
2504            let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
2505            if unpushed > 0 {
2506                bail!(
2507                    "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
2508                     on {shown}. Rebuilding it would delete their branch.\nInspect the worktree at \
2509                     {} and push or recover those commits before running this command again.",
2510                    pr.number,
2511                    path.display()
2512                );
2513            }
2514        }
2515        self.retain_ignored_pr_worktree(&path, &local, pr.number)?;
2516        self.refuse_dirty_worktree(
2517            &path,
2518            &format!("worktree for PR #{}", pr.number),
2519            Disposable::BuildOutput,
2520        )?;
2521        if !self.branch_deletion_is_safe(&local)? {
2522            bail!(
2523                "the existing branch {local} has a tip or reflog-only commit that no surviving \
2524                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
2525                 before retrying."
2526            );
2527        }
2528        if !self.remove_worktree_at(&path)? {
2529            bail!(
2530                "the existing worktree for PR #{} could not be removed safely. Its branch was \
2531                 kept.",
2532                pr.number
2533            );
2534        }
2535        if !self.delete_branch_if_safe(&local)? {
2536            bail!(
2537                "the existing branch {local} changed or remained checked out while the PR \
2538                 worktree was being rebuilt. It was kept."
2539            );
2540        }
2541
2542        let path_str = path.display().to_string();
2543        self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
2544        self.record_branch(&local, "pr", pr.number);
2545        self.set_up_worktree(&path, &format!("worktree for PR #{}", pr.number))?;
2546        Ok((path, head))
2547    }
2548
2549    /// A retry needs a fresh checkout, not permission to delete ignored data.
2550    /// Keep that data and its branch when all commits are already published.
2551    fn retain_ignored_pr_worktree(&self, path: &Path, branch: &str, number: i64) -> Result<()> {
2552        if !path.is_dir() || !self.worktree_belongs_to_repo(path)? {
2553            return Ok(());
2554        }
2555        let current =
2556            self.git_at_without_automation(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
2557        if current.trim() != branch
2558            || self.has_uncommitted_changes(path)?
2559            || !has_untracked_work_worth_keeping(path, Disposable::BuildOutput)?
2560        {
2561            return Ok(());
2562        }
2563        static NEXT: AtomicUsize = AtomicUsize::new(0);
2564        let (name, retained) = loop {
2565            let serial = NEXT.fetch_add(1, Ordering::Relaxed);
2566            let name = format!("retained-pr-{number}-{}-{serial}", std::process::id());
2567            let retained = self.worktree_path(&name);
2568            if !retained
2569                .try_exists()
2570                .map_err(|e| spar_err!("could not inspect {}: {e}", retained.display()))?
2571                && !self.exact_ref_exists_checked(&self.root, &format!("refs/heads/{name}"))?
2572            {
2573                break (name, retained);
2574            }
2575        };
2576        // Neither command overwrites an existing destination. The branch rename
2577        // preserves its reflog, and the worktree move preserves every file.
2578        self.git_at_without_automation(path, &["branch", "-m", branch, &name])?;
2579        if let Err(error) = self.git_at_without_automation(
2580            &self.root,
2581            &[
2582                "worktree",
2583                "move",
2584                &path.display().to_string(),
2585                &retained.display().to_string(),
2586            ],
2587        ) {
2588            // A locked worktree or one with submodules cannot always be moved.
2589            // Restore the branch name so a failed move does not strand a retry.
2590            let rollback =
2591                self.git_at_without_automation(&self.root, &["branch", "-m", &name, branch]);
2592            let detail = rollback
2593                .err()
2594                .map(|e| format!(" Branch name recovery also failed: {}", e.last_line()))
2595                .unwrap_or_default();
2596            bail!(
2597                "could not retain the PR checkout at {}: {}. Its files were kept.{detail}",
2598                retained.display(),
2599                error.last_line()
2600            );
2601        }
2602        logdim!(
2603            "retained ignored files and branch {name} at {}; preparing a fresh PR checkout",
2604            retained.display()
2605        );
2606        Ok(())
2607    }
2608
2609    /// Check a pull request's head out read only, detached, with no branch.
2610    ///
2611    /// Fetches `refs/pull/N/head`, which GitHub serves for every pull request
2612    /// including one from a fork whose branch is not in this repository at all.
2613    /// That is what makes reviewing an outside contribution possible when
2614    /// pushing to it is not.
2615    ///
2616    /// Detached on purpose. Review only mode has nothing to push, and a branch
2617    /// would only invite something to try.
2618    pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
2619        let path = self.worktree_path(&format!("review-{number}"));
2620        let local_ref = review_ref(number);
2621        let refspec = format!("+refs/pull/{number}/head:{local_ref}");
2622
2623        self.refuse_review_worktree_changes(number)?;
2624
2625        if let Some(parent) = path.parent() {
2626            std::fs::create_dir_all(parent)
2627                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
2628        }
2629        // Removed before the fetch, while `local_ref` still holds the head
2630        // this checkout is at. A force push moves the ref off that commit,
2631        // and the checkout's reflog would then name a commit no ref holds,
2632        // which the removal check rightly keeps as local work.
2633        if !self.remove_review_worktree_at(&path)? {
2634            bail!(
2635                "the existing review worktree for PR #{number} could not be removed safely. Its \
2636                 reference was kept."
2637            );
2638        }
2639
2640        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
2641            spar_err!(
2642                "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
2643                 every pull request, so this usually means the number is wrong or `origin` does \
2644                 not point at the repository the PR is on.",
2645                e.last_line()
2646            )
2647        })?;
2648
2649        let path_str = path.display().to_string();
2650        self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
2651        self.set_up_worktree(&path, &format!("review worktree for PR #{number}"))?;
2652        Ok(path)
2653    }
2654
2655    fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
2656        let path = self.worktree_path(&format!("review-{number}"));
2657        if !path.is_dir() {
2658            return Ok(());
2659        }
2660        let local_ref = review_ref(number);
2661        if !self.worktree_belongs_to_repo(&path)? {
2662            return Ok(());
2663        }
2664        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
2665            bail!(
2666                "the existing review worktree for PR #{number} has no recorded head at \
2667                 {local_ref}. Refusing to rebuild {}.",
2668                path.display()
2669            );
2670        }
2671        let worktree_head = self.head_oid_checked(&path)?;
2672        let recorded_head = self
2673            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
2674            .trim()
2675            .to_string();
2676        if worktree_head != recorded_head {
2677            bail!(
2678                "the existing review worktree for PR #{number} has a local commit that is not on \
2679                 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
2680                 {} before retrying.",
2681                path.display()
2682            );
2683        }
2684        self.report_recovery_markers(&path);
2685        self.refuse_dirty_worktree(
2686            &path,
2687            &format!("review worktree for PR #{number}"),
2688            Disposable::ReviewOutput,
2689        )?;
2690        Ok(())
2691    }
2692
2693    /// Read out what an earlier run refused to explain away, before rebuilding
2694    /// over it.
2695    ///
2696    /// The marker is the only record of why a call's answer was thrown out, and
2697    /// a review checkout is rebuilt on the next run. Saying it here is what
2698    /// keeps the file from being the thing that quietly blocks the command
2699    /// instead.
2700    fn report_recovery_markers(&self, path: &Path) {
2701        let Ok(entries) = std::fs::read_dir(path) else {
2702            return;
2703        };
2704        for entry in entries.flatten() {
2705            let name = entry.file_name();
2706            let Some(name) = name.to_str() else { continue };
2707            if !name.starts_with(RECOVERY_MARKER_PREFIX) {
2708                continue;
2709            }
2710            match std::fs::read_to_string(entry.path()) {
2711                Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
2712                Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
2713            }
2714        }
2715    }
2716
2717    /// A worktree for one part of a split, on a new branch off `start`.
2718    ///
2719    /// `start` is the base branch for independent parts and the previous part's
2720    /// branch for stacked ones, which is the only difference between the two
2721    /// shapes at this level.
2722    ///
2723    /// The branch is whatever name was free, which is why it is returned rather
2724    /// than derived by the caller. Splitting the same pull request a second
2725    /// time would otherwise target the branch behind the first run's pull
2726    /// request. Split pushes are create-only and would refuse that target, but
2727    /// a repeated split still needs distinct branches rather than a name that
2728    /// can never be created.
2729    pub fn worktree_for_split(
2730        &self,
2731        parent: i64,
2732        index: usize,
2733        start: &str,
2734    ) -> Result<(PathBuf, String)> {
2735        let slot = self.free_split_slot(parent, index)?;
2736        let branch = format!("{}{slot}", self.branch_prefix);
2737        let path = self.worktree_path(&slot);
2738
2739        if let Some(dir) = path.parent() {
2740            std::fs::create_dir_all(dir)
2741                .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
2742        }
2743        self.refuse_dirty_worktree(
2744            &path,
2745            &format!("worktree for part {index} of PR #{parent}"),
2746            Disposable::BuildOutput,
2747        )?;
2748        // The name is free, so there is no branch to delete. A directory can
2749        // still be in the way, left by a worktree that was pruned from git's
2750        // records without being removed from disk.
2751        if !self.remove_worktree_at(&path)? {
2752            bail!(
2753                "the existing worktree for part {index} of PR #{parent} could not be removed \
2754                 safely. No branch was created."
2755            );
2756        }
2757
2758        let path_str = path.display().to_string();
2759        self.git(&["worktree", "add", "-b", &branch, &path_str, start])
2760            .map_err(|e| {
2761                spar_err!(
2762                    "could not create a worktree for part {index} of #{parent}. {}",
2763                    e.last_line()
2764                )
2765            })?;
2766        // Recorded before anything else can fail. An unrecorded branch is one
2767        // `prune_branches` will never remove.
2768        self.record_branch(&branch, "split", parent);
2769        self.set_up_worktree(&path, &format!("worktree for part {index} of PR #{parent}"))?;
2770        Ok((path, branch))
2771    }
2772
2773    /// The first part branch nothing is already sitting on.
2774    ///
2775    /// Origin as well as local, because a part's branch outlives the local one:
2776    /// a second split of the same pull request finds its own earlier branches
2777    /// deleted here but alive on origin, where the pull requests that reviewed
2778    /// them still point at them.
2779    fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2780        for attempt in 1..=SPLIT_SLOTS {
2781            let slot = split_slot(parent, index, attempt);
2782            let branch = format!("{}{slot}", self.branch_prefix);
2783            self.git_try(&["fetch", "origin", &branch]);
2784            if !self.rev_exists(&self.root, &branch)
2785                && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2786            {
2787                return Ok(slot);
2788            }
2789        }
2790        bail!(
2791            "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2792             names are all taken. Inspect the existing branches and child pull requests. Finish \
2793             recording the earlier split, or remove every retained local worktree and branch, \
2794             child pull request, and remote split branch before starting over.",
2795            self.branch_for_split(parent, index)
2796        )
2797    }
2798
2799    /// Whether a previous attempt pushed any branch for this split.
2800    ///
2801    /// The parent comment is the normal retry marker. A branch is the fallback
2802    /// when that comment or the pull request creation failed after the push.
2803    /// Reading origin directly makes the guard survive a fresh clone.
2804    pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2805        let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2806        Ok(!self
2807            .git(&["ls-remote", "--heads", "origin", &pattern])?
2808            .trim()
2809            .is_empty())
2810    }
2811
2812    /// Throw one part away: its worktree, its branch, and its record.
2813    ///
2814    /// For a part that would not stand on its own. Nothing has been pushed at
2815    /// that point, so this leaves no trace anywhere but the log. Takes what
2816    /// `worktree_for_split` returned, since the name it settled on is not
2817    /// derivable from the parent and the index.
2818    pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2819        match self.branch_deletion_is_safe(branch) {
2820            Ok(true) => {}
2821            Ok(false) => {
2822                logdim!(
2823                    "kept {branch} and {} because no surviving ref preserves its tip",
2824                    dir.display()
2825                );
2826                return;
2827            }
2828            Err(error) => {
2829                logdim!(
2830                    "kept {branch} and {} because preservation could not be verified: {}",
2831                    dir.display(),
2832                    error.last_line()
2833                );
2834                return;
2835            }
2836        }
2837        match self.remove_worktree_at(dir) {
2838            Ok(true) => match self.delete_branch_if_safe(branch) {
2839                Ok(true) => self.forget_branch(branch),
2840                Ok(false) => {
2841                    logdim!("kept {branch} because its tip or reflog changed before deletion")
2842                }
2843                Err(error) => logdim!(
2844                    "kept {branch} because deletion safety could not be rechecked: {}",
2845                    error.last_line()
2846                ),
2847            },
2848            Ok(false) => {}
2849            Err(error) => logdim!(
2850                "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
2851                dir.display(),
2852                error.last_line()
2853            ),
2854        }
2855    }
2856
2857    /// Discard one exact mechanical slice that the split workflow just made.
2858    ///
2859    /// Unlike ordinary release, this intentionally removes an unpushed commit.
2860    /// The caller supplies the exact disposable tip, and every file, worktree,
2861    /// ownership, and ref check must still match before anything is removed.
2862    pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
2863        let record = self.known_branches().get(branch).cloned();
2864        if record.is_none_or(|record| record.kind != "split") {
2865            logdim!("kept {branch} because no split branch record proves ownership");
2866            return false;
2867        }
2868        let local_ref = format!("refs/heads/{branch}");
2869        let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
2870            Ok(value) => value.trim().to_string(),
2871            Err(error) => {
2872                logdim!(
2873                    "kept {branch} because its tip could not be checked: {}",
2874                    error.last_line()
2875                );
2876                return false;
2877            }
2878        };
2879        if expected != disposable_head {
2880            logdim!("kept {branch} because it moved beyond the disposable slice");
2881            return false;
2882        }
2883        match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
2884            Ok(true) => {}
2885            Ok(false) => {
2886                logdim!(
2887                    "kept {branch} because its reflog contains work outside the disposable slice"
2888                );
2889                return false;
2890            }
2891            Err(error) => {
2892                logdim!(
2893                    "kept {branch} because its reflog could not be checked: {}",
2894                    error.last_line()
2895                );
2896                return false;
2897            }
2898        }
2899        match self.head_oid_checked(dir) {
2900            Ok(head) if head == disposable_head => {}
2901            Ok(_) => {
2902                logdim!(
2903                    "kept {branch} and {} because the worktree moved beyond the disposable slice",
2904                    dir.display()
2905                );
2906                return false;
2907            }
2908            Err(error) => {
2909                logdim!(
2910                    "kept {branch} and {} because its head could not be checked: {}",
2911                    dir.display(),
2912                    error.last_line()
2913                );
2914                return false;
2915            }
2916        }
2917        match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
2918            Ok(true) => {}
2919            Ok(false) => return false,
2920            Err(error) => {
2921                logdim!(
2922                    "kept {branch} and {} because the disposable slice could not be verified: {}",
2923                    dir.display(),
2924                    error.last_line()
2925                );
2926                return false;
2927            }
2928        }
2929        if let Err(error) =
2930            self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
2931        {
2932            logdim!(
2933                "kept {branch} because its exact disposable tip could not be deleted: {}",
2934                error.last_line()
2935            );
2936            return false;
2937        }
2938        match self.exact_ref_exists_checked(&self.root, &local_ref) {
2939            Ok(false) => {
2940                self.forget_branch(branch);
2941                true
2942            }
2943            Ok(true) => {
2944                logdim!("kept {branch} because its ref still exists after deletion");
2945                false
2946            }
2947            Err(error) => {
2948                logdim!(
2949                    "kept the branch record for {branch} because deletion could not be verified: {}",
2950                    error.last_line()
2951                );
2952                false
2953            }
2954        }
2955    }
2956
2957    pub fn release_review_worktree(&self, number: i64) {
2958        let path = self.worktree_path(&format!("review-{number}"));
2959        match self.review_ref_deletion_is_safe(number) {
2960            Ok(true) => {}
2961            Ok(false) => {
2962                logdim!(
2963                    "kept {} because no surviving ref preserves its review history",
2964                    path.display()
2965                );
2966                return;
2967            }
2968            Err(error) => {
2969                logdim!(
2970                    "kept {} because review history could not be verified: {}",
2971                    path.display(),
2972                    error.last_line()
2973                );
2974                return;
2975            }
2976        }
2977        match self.remove_worktree_at(&path) {
2978            Ok(true) => match self.delete_review_ref_if_safe(number) {
2979                Ok(true) => {}
2980                Ok(false) => logdim!(
2981                    "kept {} because its review history changed before deletion",
2982                    review_ref(number)
2983                ),
2984                Err(error) => logdim!(
2985                    "kept {} because deletion safety could not be rechecked: {}",
2986                    review_ref(number),
2987                    error.last_line()
2988                ),
2989            },
2990            Ok(false) => {}
2991            Err(error) => logdim!(
2992                "kept {} because removal did not reach a confirmed quiet point: {}",
2993                path.display(),
2994                error.last_line()
2995            ),
2996        }
2997    }
2998
2999    /// Release a read-only review checkout only when every observed part of
3000    /// its Git state still matches the checkpoint captured before the calls.
3001    pub(crate) fn release_review_worktree_checked(
3002        &self,
3003        number: i64,
3004        checkpoint: &WorktreeCheckpoint,
3005    ) -> Result<()> {
3006        let path = self.worktree_path(&format!("review-{number}"));
3007        self.require_unchanged_worktree(
3008            &path,
3009            checkpoint,
3010            &format!("review worktree for PR #{number}"),
3011        )?;
3012        if !self.review_ref_deletion_is_safe(number)? {
3013            bail!(
3014                "the review reference for PR #{number} has reflog-only recovery history. The \
3015                 worktree and reference were kept."
3016            );
3017        }
3018        if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
3019            bail!(
3020                "the verified review worktree at {} could not be removed, so its reference was \
3021                 kept",
3022                path.display()
3023            );
3024        }
3025        if !self.delete_review_ref_if_safe(number)? {
3026            bail!(
3027                "the review reference for PR #{number} changed before deletion. The reference was \
3028                 kept."
3029            );
3030        }
3031        Ok(())
3032    }
3033
3034    pub fn release_pr_worktree(&self, number: i64) -> bool {
3035        let path = self.worktree_path(&format!("pr-{number}"));
3036        let local = self.branch_for_pr(number);
3037        match self.branch_deletion_is_safe(&local) {
3038            Ok(true) => {}
3039            Ok(false) => {
3040                logdim!(
3041                    "kept {local} and {} because no surviving ref preserves its tip",
3042                    path.display()
3043                );
3044                return false;
3045            }
3046            Err(error) => {
3047                logdim!(
3048                    "kept {local} and {} because preservation could not be verified: {}",
3049                    path.display(),
3050                    error.last_line()
3051                );
3052                return false;
3053            }
3054        }
3055        match self.remove_worktree_at(&path) {
3056            Ok(true) => match self.delete_branch_if_safe(&local) {
3057                Ok(true) => {
3058                    self.forget_branch(&local);
3059                    true
3060                }
3061                Ok(false) => {
3062                    logdim!("kept {local} because its tip or reflog changed before deletion");
3063                    false
3064                }
3065                Err(error) => {
3066                    logdim!(
3067                        "kept {local} because deletion safety could not be rechecked: {}",
3068                        error.last_line()
3069                    );
3070                    false
3071                }
3072            },
3073            Ok(false) => false,
3074            Err(error) => {
3075                logdim!(
3076                    "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
3077                    path.display(),
3078                    error.last_line()
3079                );
3080                false
3081            }
3082        }
3083    }
3084
3085    // -- branch state -----------------------------------------------------
3086
3087    /// What to diff against: the remote tracking branch when it resolves, the
3088    /// local branch when it does not.
3089    ///
3090    /// This is not a nicety. Every "did the agent do anything" check hangs off
3091    /// this ref, and `git log` against a ref that does not exist fails silently
3092    /// and reads as "no commits". A checkout whose `origin/main` was never
3093    /// fetched would report every implementation as abandoned and throw the
3094    /// work away.
3095    pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
3096        let remote = format!("origin/{base}");
3097        if self.rev_exists(cwd, &remote) {
3098            return remote;
3099        }
3100        if self.rev_exists(cwd, base) {
3101            logdim!("origin/{base} does not resolve, comparing against local {base}");
3102            return base.to_string();
3103        }
3104        logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
3105        remote
3106    }
3107
3108    fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
3109        let spec = format!("{refname}^{{commit}}");
3110        !self
3111            .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
3112            .trim()
3113            .is_empty()
3114    }
3115
3116    pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
3117        let range = format!("{}..HEAD", self.base_ref(cwd, base));
3118        !self
3119            .git_try_at(Some(cwd), &["log", &range, "--oneline"])
3120            .trim()
3121            .is_empty()
3122    }
3123
3124    fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
3125        let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
3126        Ok(found.lines().any(|line| line.trim() == refname))
3127    }
3128
3129    fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
3130        let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
3131        count.trim().parse::<usize>().map_err(|e| {
3132            spar_err!(
3133                "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
3134                count.trim()
3135            )
3136        })
3137    }
3138
3139    pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
3140        let remote = format!("refs/remotes/origin/{base}");
3141        if self.exact_ref_exists_checked(cwd, &remote)? {
3142            return Ok(remote);
3143        }
3144        let local = format!("refs/heads/{base}");
3145        if self.exact_ref_exists_checked(cwd, &local)? {
3146            return Ok(local);
3147        }
3148        bail!("neither origin/{base} nor local branch {base} resolves")
3149    }
3150
3151    pub(crate) fn commit_count_checked(
3152        &self,
3153        cwd: &Path,
3154        refname: &str,
3155        base: &str,
3156    ) -> Result<usize> {
3157        let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
3158        let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
3159        count.trim().parse::<usize>().map_err(|e| {
3160            spar_err!(
3161                "git returned an invalid commit count for {range}: {:?} ({e})",
3162                count.trim()
3163            )
3164        })
3165    }
3166
3167    pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
3168        Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
3169    }
3170
3171    pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
3172        let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
3173        let head = head.trim().to_string();
3174        if head.is_empty() {
3175            bail!("git returned an empty HEAD for {}", cwd.display());
3176        }
3177        Ok(head)
3178    }
3179
3180    /// Whether a recorded SPAR branch's exact tip is retained by a pull request.
3181    ///
3182    /// Pull request head refs remain available after close or merge, so this is
3183    /// stronger than requiring an open pull request or a live remote branch.
3184    pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
3185        let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
3186        self.local_branch_is_preserved(branch.trim())
3187    }
3188
3189    /// Whether a recorded local branch's exact tip is retained by its pull
3190    /// request head, regardless of which branch is currently checked out.
3191    pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
3192        let known = self.known_branches();
3193        let Some(record) = known.get(branch) else {
3194            return Ok(false);
3195        };
3196        self.branch_is_preserved_checked(branch, record)
3197    }
3198
3199    /// Whether tracked, staged, or non-ignored untracked files are uncommitted.
3200    pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
3201        has_uncommitted_work(cwd)
3202    }
3203
3204    /// Whether removing a worktree would delete any local file Git does not
3205    /// reproduce from its commits, including ignored untracked files.
3206    fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
3207        repository_has_recoverable_work_with(cwd, true, disposable)
3208    }
3209
3210    /// Record ignored artifacts that existed before an editing call.
3211    pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
3212        let attributes = attribute_state(cwd)?;
3213        Ok(WorktreeBaseline {
3214            attributes,
3215            ignored_untracked: ignored_untracked_state(cwd)?,
3216            git_state: safe_git_state(cwd)?,
3217        })
3218    }
3219
3220    /// Capture the Git state of a checkout intended to remain read only while
3221    /// external commands inspect it.
3222    pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
3223        let attributes = attribute_state(cwd)?;
3224        Ok(WorktreeCheckpoint {
3225            path: std::fs::canonicalize(cwd)
3226                .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
3227            attributes,
3228            git_state: safe_git_state(cwd)?,
3229            ignored_untracked: ignored_untracked_state(cwd)?,
3230        })
3231    }
3232
3233    /// Require a read-only checkout to match a previously captured checkpoint.
3234    /// Any probe failure is an error because deletion cannot be proven safe.
3235    pub(crate) fn require_unchanged_worktree(
3236        &self,
3237        cwd: &Path,
3238        checkpoint: &WorktreeCheckpoint,
3239        label: &str,
3240    ) -> Result<()> {
3241        let resolved = std::fs::canonicalize(cwd).map_err(|e| {
3242            crate::error::SparError::uncertain_write(format!(
3243                "could not resolve the {label} at {} after inspection: {e}. It was kept.",
3244                cwd.display()
3245            ))
3246        })?;
3247        if resolved != checkpoint.path {
3248            return Err(uncertain_worktree_change(
3249                cwd,
3250                format!(
3251                    "the {label} moved from {} to {} during inspection. It was kept.",
3252                    checkpoint.path.display(),
3253                    resolved.display()
3254                ),
3255            ));
3256        }
3257        let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
3258            uncertain_worktree_change(
3259                cwd,
3260                format!(
3261                    "could not verify attribute files in the {label} at {}: {}. It was kept.",
3262                    cwd.display(),
3263                    e.last_line()
3264                ),
3265            )
3266        })?;
3267        if attributes != checkpoint.attributes {
3268            return Err(uncertain_worktree_change(
3269                cwd,
3270                format!(
3271                    "attribute files in the {label} at {} changed during inspection. It was \
3272                     kept for recovery.",
3273                    cwd.display()
3274                ),
3275            ));
3276        }
3277        let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
3278            uncertain_worktree_change(
3279                cwd,
3280                format!(
3281                    "could not verify the Git state of the {label} at {}: {}. It was kept.",
3282                    cwd.display(),
3283                    e.last_line()
3284                ),
3285            )
3286        })?;
3287        let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
3288            uncertain_worktree_change(
3289                cwd,
3290                format!(
3291                    "could not verify untracked files in the {label} at {}: {}. It was kept.",
3292                    cwd.display(),
3293                    e.last_line()
3294                ),
3295            )
3296        })?;
3297        let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
3298        // Said even when the inspection is accepted, for the same reason it is
3299        // said on an agent call: a path the project ignores is out of scope,
3300        // not unnoticed.
3301        note_ignored_leavings(cwd, "inspection", &leavings.tolerated);
3302        let tracked = checkpoint.git_state.describe_difference(&git_state);
3303        let untracked = (!leavings.condemning.is_empty()).then(|| {
3304            format!(
3305                "untracked file(s) changed: {}",
3306                list_paths(&leavings.condemning)
3307            )
3308        });
3309        let Some(reason) = join_reasons(tracked, untracked) else {
3310            return Ok(());
3311        };
3312        Err(uncertain_worktree_change(
3313            cwd,
3314            format!(
3315                "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
3316                 for recovery.",
3317                cwd.display()
3318            ),
3319        ))
3320    }
3321
3322    /// Accept project-ignored files left by a successful editing call.
3323    /// Ordinary untracked files must already be represented by the commit.
3324    /// Acceptance does not authorize cleanup of the ignored files.
3325    pub(crate) fn refuse_new_ignored_files(
3326        &self,
3327        cwd: &Path,
3328        baseline: &WorktreeBaseline,
3329    ) -> Result<()> {
3330        self.allow_ignored_files(cwd, baseline).map(drop)
3331    }
3332
3333    /// Preserve unexpected output when the editing call did not finish.
3334    pub(crate) fn refuse_failed_edit_leavings(
3335        &self,
3336        cwd: &Path,
3337        baseline: &WorktreeBaseline,
3338    ) -> Result<()> {
3339        self.check_new_ignored_files(cwd, baseline, IgnoredState::generated_move)
3340            .map(drop)
3341    }
3342
3343    /// Reject ordinary untracked changes after a read-only call.
3344    pub(crate) fn refuse_read_only_leavings(
3345        &self,
3346        cwd: &Path,
3347        baseline: &WorktreeBaseline,
3348    ) -> Result<()> {
3349        self.refuse_changed_attributes(cwd, baseline)?;
3350        let after = ignored_untracked_state(cwd).map_err(|e| {
3351            uncertain_worktree_change(
3352                cwd,
3353                format!(
3354                    "could not verify untracked files in {} after a read-only call: {}. The \
3355                     worktree was kept for recovery.",
3356                    cwd.display(),
3357                    e.last_line()
3358                ),
3359            )
3360        })?;
3361        let leavings = baseline.ignored_untracked.read_only_leavings(&after);
3362        if leavings.condemning.is_empty() {
3363            return Ok(());
3364        }
3365        Err(uncertain_worktree_change(
3366            cwd,
3367            format!(
3368                "the read-only call left untracked file(s) in {} that the project does not \
3369                 ignore: {}. The worktree was kept for recovery.",
3370                cwd.display(),
3371                list_paths(&leavings.condemning)
3372            ),
3373        ))
3374    }
3375
3376    /// The ignored paths the check let through, for one report per attempt
3377    /// rather than one per check.
3378    fn allow_ignored_files(
3379        &self,
3380        cwd: &Path,
3381        baseline: &WorktreeBaseline,
3382    ) -> Result<AllowedArtifacts> {
3383        self.check_new_ignored_files(cwd, baseline, IgnoredState::ignored_file_move)
3384    }
3385
3386    fn check_new_ignored_files(
3387        &self,
3388        cwd: &Path,
3389        baseline: &WorktreeBaseline,
3390        allowed_move: fn(&IgnoredState, &IgnoredState, &Path) -> bool,
3391    ) -> Result<AllowedArtifacts> {
3392        self.refuse_changed_attributes(cwd, baseline)?;
3393        let after = ignored_untracked_state(cwd).map_err(|e| {
3394            uncertain_worktree_change(
3395                cwd,
3396                format!(
3397                    "could not verify untracked files in {} after editing: {}. The worktree was \
3398                     kept for recovery.",
3399                    cwd.display(),
3400                    e.last_line()
3401                ),
3402            )
3403        })?;
3404        let changed = baseline.ignored_untracked.changed_paths(&after);
3405        if changed.is_empty() {
3406            return Ok(AllowedArtifacts::default());
3407        }
3408        let (ignored, changed): (Vec<_>, Vec<_>) = changed
3409            .into_iter()
3410            .partition(|path| allowed_move(&baseline.ignored_untracked, &after, path));
3411        if changed.is_empty() {
3412            return Ok(AllowedArtifacts::split(ignored, &after));
3413        }
3414        let listed = list_paths(&changed);
3415        Err(uncertain_worktree_change(
3416            cwd,
3417            format!(
3418                "the editing call created or changed untracked or ignored file(s) in {} that \
3419                 cannot be represented by a managed commit: {listed}. The worktree was kept for \
3420                 recovery.",
3421                cwd.display()
3422            ),
3423        ))
3424    }
3425
3426    /// Existing untracked files belong to the checkout owner, even when a call
3427    /// also produces a valid tracked change. Refuse their modification or
3428    /// deletion before accepting the tracked result. Project-ignored files
3429    /// stay outside the commit, wherever the project puts them.
3430    pub(crate) fn refuse_changed_existing_untracked(
3431        &self,
3432        cwd: &Path,
3433        baseline: &WorktreeBaseline,
3434    ) -> Result<()> {
3435        self.check_changed_existing_untracked(cwd, baseline)
3436            .map(drop)
3437    }
3438
3439    fn allow_changed_ignored_artifacts(
3440        &self,
3441        cwd: &Path,
3442        baseline: &WorktreeBaseline,
3443    ) -> Result<AllowedArtifacts> {
3444        self.check_changed_existing_untracked(cwd, baseline)
3445    }
3446
3447    fn check_changed_existing_untracked(
3448        &self,
3449        cwd: &Path,
3450        baseline: &WorktreeBaseline,
3451    ) -> Result<AllowedArtifacts> {
3452        self.refuse_changed_attributes(cwd, baseline)?;
3453        let after = ignored_untracked_state(cwd).map_err(|e| {
3454            uncertain_worktree_change(
3455                cwd,
3456                format!(
3457                    "could not verify existing untracked files in {} after editing: {}. The \
3458                     worktree was kept for recovery.",
3459                    cwd.display(),
3460                    e.last_line()
3461                ),
3462            )
3463        })?;
3464        let changed = baseline.ignored_untracked.changed_existing_paths(&after);
3465        if changed.is_empty() {
3466            return Ok(AllowedArtifacts::default());
3467        }
3468        let (ignored, changed): (Vec<_>, Vec<_>) = changed
3469            .into_iter()
3470            .partition(|path| baseline.ignored_untracked.ignored_file_move(&after, path));
3471        if changed.is_empty() {
3472            return Ok(AllowedArtifacts::split(ignored, &after));
3473        }
3474        let listed = list_paths(&changed);
3475        Err(uncertain_worktree_change(
3476            cwd,
3477            format!(
3478                "the editing call changed or deleted existing untracked file(s) in {}: \
3479                 {listed}. The worktree was kept for recovery.",
3480                cwd.display()
3481            ),
3482        ))
3483    }
3484
3485    /// Refuse a byte or mode change that the index did not represent.
3486    ///
3487    /// Clean filters can normalize a working file back to its existing blob,
3488    /// and index flags can hide a change from porcelain status. Comparing the
3489    /// actual tracked files on both sides keeps those bytes from being treated
3490    /// as disposable just because Git has no diff for them.
3491    pub(crate) fn refuse_unrepresented_tracked_changes(
3492        &self,
3493        cwd: &Path,
3494        baseline: &WorktreeBaseline,
3495    ) -> Result<()> {
3496        self.refuse_changed_attributes(cwd, baseline)?;
3497        let after = safe_git_state(cwd).map_err(|e| {
3498            uncertain_worktree_change(
3499                cwd,
3500                format!(
3501                    "could not verify tracked files in {} after editing: {}. The worktree was \
3502                     kept for recovery.",
3503                    cwd.display(),
3504                    e.last_line()
3505                ),
3506            )
3507        })?;
3508        let mut changed = Vec::new();
3509        let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
3510            uncertain_worktree_change(
3511                cwd,
3512                format!(
3513                    "could not record untracked files before verifying transformed content in {}: \
3514                     {}. The worktree was kept for recovery.",
3515                    cwd.display(),
3516                    e.last_line()
3517                ),
3518            )
3519        })?;
3520        let mut filter_was_run = false;
3521        let mut filter_problem = None;
3522        let mut repositories: BTreeSet<PathBuf> =
3523            baseline.git_state.repositories.keys().cloned().collect();
3524        repositories.extend(after.repositories.keys().cloned());
3525        'repositories: for repository_path in repositories {
3526            let before_repository = baseline.git_state.repositories.get(&repository_path);
3527            let after_repository = after.repositories.get(&repository_path);
3528            if before_repository.is_none() || after_repository.is_none() {
3529                changed.push(repository_path.clone());
3530                continue;
3531            }
3532            if before_repository.map(|repository| &repository.gitlinks)
3533                != after_repository.map(|repository| &repository.gitlinks)
3534            {
3535                changed.push(repository_path.join("<gitlinks>"));
3536            }
3537            let mut paths = BTreeSet::new();
3538            if let Some(repository) = before_repository {
3539                paths.extend(repository.tracked.keys().cloned());
3540            }
3541            if let Some(repository) = after_repository {
3542                paths.extend(repository.tracked.keys().cloned());
3543            }
3544            for path in paths {
3545                let before = before_repository.and_then(|repository| repository.tracked.get(&path));
3546                let current = after_repository.and_then(|repository| repository.tracked.get(&path));
3547                let worktree_changed =
3548                    before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
3549                let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
3550                    != current.map(|entry| (&entry.index_mode, &entry.index_oid));
3551                if !worktree_changed {
3552                    continue;
3553                }
3554                if !index_changed {
3555                    changed.push(repository_path.join(&path));
3556                    continue;
3557                }
3558                let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
3559                let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
3560                let Some(current_entry) = current else {
3561                    continue;
3562                };
3563                let Some(current_worktree) = current_worktree else {
3564                    continue;
3565                };
3566                let content_changed =
3567                    before_worktree.map(|file| file.content) != Some(current_worktree.content);
3568                let mode_changed = before_worktree.map(|file| file.mode.as_str())
3569                    != Some(current_worktree.mode.as_str());
3570                let repository = cwd.join(&repository_path);
3571                let represented_content = if content_changed {
3572                    filter_was_run = true;
3573                    let result =
3574                        filtered_index_content(&repository, &path, &current_entry.index_oid);
3575                    self.refuse_changed_attributes(cwd, baseline)?;
3576                    match result {
3577                        Ok(expected) => expected == current_worktree.content,
3578                        Err(error) => {
3579                            filter_problem = Some(format!(
3580                                "could not verify transformed content for {:?}: {}",
3581                                repository_path.join(&path),
3582                                error.last_line()
3583                            ));
3584                            false
3585                        }
3586                    }
3587                } else {
3588                    true
3589                };
3590                let represented_mode =
3591                    !mode_changed || current_worktree.mode == current_entry.index_mode;
3592                if !represented_content || !represented_mode {
3593                    changed.push(repository_path.join(&path));
3594                }
3595                if filter_problem.is_some() {
3596                    break 'repositories;
3597                }
3598            }
3599        }
3600        if filter_was_run {
3601            self.refuse_changed_attributes(cwd, baseline)?;
3602            let verified = safe_git_state(cwd).map_err(|e| {
3603                uncertain_worktree_change(
3604                    cwd,
3605                    format!(
3606                        "could not recheck tracked files after verifying transformed content in \
3607                         {}: {}. The worktree was kept for recovery.",
3608                        cwd.display(),
3609                        e.last_line()
3610                    ),
3611                )
3612            })?;
3613            let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
3614                uncertain_worktree_change(
3615                    cwd,
3616                    format!(
3617                        "could not recheck untracked files after verifying transformed content \
3618                         in {}: {}. The worktree was kept for recovery.",
3619                        cwd.display(),
3620                        e.last_line()
3621                    ),
3622                )
3623            })?;
3624            if verified != after
3625                || before_filter_untracked.changed_beyond_generated(&verified_untracked)
3626            {
3627                return Err(uncertain_worktree_change(
3628                    cwd,
3629                    "a content filter changed the worktree while SPAR verified the managed \
3630                     commit. The worktree was kept for recovery.",
3631                ));
3632            }
3633            self.refuse_changed_existing_untracked(cwd, baseline)?;
3634        }
3635        if let Some(problem) = filter_problem {
3636            return Err(uncertain_worktree_change(
3637                cwd,
3638                format!("{problem}. The worktree was kept for recovery."),
3639            ));
3640        }
3641        if changed.is_empty() {
3642            return Ok(());
3643        }
3644        let listed = list_paths(&changed);
3645        Err(uncertain_worktree_change(
3646            cwd,
3647            format!(
3648                "the editing call changed tracked working-file bytes, modes, repositories, or \
3649                 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
3650                 recovery."
3651            ),
3652        ))
3653    }
3654
3655    pub(crate) fn refuse_changed_attributes(
3656        &self,
3657        cwd: &Path,
3658        baseline: &WorktreeBaseline,
3659    ) -> Result<()> {
3660        let after = attribute_state(cwd).map_err(|e| {
3661            uncertain_worktree_change(
3662                cwd,
3663                format!(
3664                    "could not verify attribute files in {} after editing: {}. The worktree was \
3665                     kept for recovery.",
3666                    cwd.display(),
3667                    e.last_line()
3668                ),
3669            )
3670        })?;
3671        if after == baseline.attributes {
3672            return Ok(());
3673        }
3674        Err(uncertain_worktree_change(
3675            cwd,
3676            format!(
3677                "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
3678                 refused to run a Git operation that could select a new external filter.",
3679                cwd.display()
3680            ),
3681        ))
3682    }
3683
3684    /// Commit a successful editing call from the trusted harness process.
3685    ///
3686    /// Editing sandboxes only need the working tree. They never need writable
3687    /// access to the repository's object database, refs, config, or hooks.
3688    pub(crate) fn commit_pending_changes(
3689        &self,
3690        cwd: &Path,
3691        baseline: &WorktreeBaseline,
3692        preferred_subject: &str,
3693        fallback_subject: &str,
3694    ) -> Result<bool> {
3695        let mut artifacts = IgnoredArtifacts::default();
3696        self.refuse_changed_attributes(cwd, baseline)?;
3697        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3698        refuse_unsafe_index_flags(cwd)?;
3699        if !self.has_uncommitted_changes(cwd)? {
3700            artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3701            artifacts.report(cwd);
3702            return Ok(false);
3703        }
3704        self.stage_managed_changes(cwd, baseline).map_err(|e| {
3705            e.with_message(format!(
3706                "could not stage changes in {}: {}",
3707                cwd.display(),
3708                e.last_line()
3709            ))
3710        })?;
3711        // Git ignore rules decide which files stay outside the commit.
3712        // Whether cleanup can delete them is a separate decision.
3713        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3714        let changed_gitlinks = changed_staged_gitlinks(cwd)?;
3715        if !changed_gitlinks.is_empty() {
3716            let listed = list_paths(&changed_gitlinks);
3717            bail!(
3718                "the editing call added or changed a gitlink at {listed}. It was staged but not \
3719                 committed because the referenced repository objects might exist only inside \
3720                 this worktree. The worktree was kept for recovery."
3721            );
3722        }
3723        let mut subject = self.clean_title(preferred_subject)?;
3724        if subject.trim().is_empty() {
3725            subject = self.clean_title(fallback_subject)?;
3726        }
3727        self.commit_staged_changes(cwd, &subject).map_err(|e| {
3728            e.with_message(format!(
3729                "could not commit changes in {}: {}. The staged files were kept.",
3730                cwd.display(),
3731                e.last_line()
3732            ))
3733        })?;
3734        if has_tracked_or_staged_work(cwd)? {
3735            bail!(
3736                "the commit in {} left additional uncommitted files. They were kept for \
3737                 recovery.",
3738                cwd.display()
3739            );
3740        }
3741        artifacts.changed(self.allow_changed_ignored_artifacts(cwd, baseline)?);
3742        artifacts.left(self.allow_ignored_files(cwd, baseline)?);
3743        artifacts.report(cwd);
3744        Ok(true)
3745    }
3746
3747    fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
3748        let after = ignored_untracked_state(cwd)?;
3749        self.git_at_without_automation(cwd, &["add", "-u"])?;
3750        let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
3751        if paths.is_empty() {
3752            return Ok(());
3753        }
3754        let mut input = Vec::new();
3755        for path in paths {
3756            input.extend(os_str_bytes(path.as_os_str())?);
3757            input.push(0);
3758        }
3759        let argv = git_without_automation_argv(&[
3760            "--literal-pathspecs",
3761            "add",
3762            "--pathspec-from-file=-",
3763            "--pathspec-file-nul",
3764        ]);
3765        proc::run_with_input_bytes(
3766            &argv,
3767            &self.git_opts(Some(cwd), true).stop_descendants(true),
3768            &input,
3769        )?;
3770        Ok(())
3771    }
3772
3773    /// Commit an index prepared by the parent without signing, hooks, or
3774    /// inherited repository automation.
3775    pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
3776        self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
3777            .map(|_| ())
3778    }
3779
3780    /// How many commits `refname` carries that the base does not.
3781    ///
3782    /// Counted from the commits themselves rather than from `commit_subjects`,
3783    /// which drops a commit whose message is empty. The guards in
3784    /// `worktree_add` decide whether to delete a branch on this number, and an
3785    /// empty message must not read as an empty branch.
3786    pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
3787        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3788        self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
3789            .trim()
3790            .parse()
3791            .unwrap_or(0)
3792    }
3793
3794    /// One `hash subject` line per commit `refname` carries that the base does
3795    /// not, oldest first. For showing a person what is on a branch, so the
3796    /// hash keeps a commit with no message from listing as nothing.
3797    pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3798        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3799        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3800            .lines()
3801            .map(str::to_string)
3802            .collect()
3803    }
3804
3805    /// The commits `later` carries that `earlier` does not, oldest first, when
3806    /// `earlier` is genuinely behind it.
3807    ///
3808    /// `None` when it is not an ancestor, which is not the same as nothing
3809    /// having landed. `rewrite_commits_if_needed` rewrites hashes from the first
3810    /// offending commit spar made onward, so a head recorded part way through a
3811    /// round can still be a readable object and no longer be on the branch. `git log` answers that
3812    /// with every commit on the branch, so without the check the one caller
3813    /// would report the whole branch as unread, which is the widest possible
3814    /// wrong answer.
3815    ///
3816    /// No `base_ref` resolution, unlike its neighbours: these are commits rather
3817    /// than branch names, and putting a sha through it logs a fallback line
3818    /// every time.
3819    pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3820        let ancestor = self
3821            .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3822            .is_ok();
3823        if !ancestor {
3824            return None;
3825        }
3826        let range = format!("{earlier}..{later}");
3827        Some(
3828            self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3829                .lines()
3830                .map(str::to_string)
3831                .collect(),
3832        )
3833    }
3834
3835    /// The subjects of the commits `refname` carries that the base does not,
3836    /// oldest first.
3837    pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3838        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3839        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3840            .lines()
3841            .map(str::trim)
3842            .filter(|line| !line.is_empty())
3843            .map(str::to_string)
3844            .collect()
3845    }
3846
3847    /// The paths this checkout changes relative to the base, sorted.
3848    ///
3849    /// A three dot range, matching `diff_stat`: what the branch did, not what
3850    /// the base has done since.
3851    ///
3852    /// `--no-renames` because a rename reported as its destination alone leaves
3853    /// the source out of the list, and a part carrying only the destination
3854    /// would be a copy. As a deletion and an addition it is two paths, which a
3855    /// part can carry together or leave to the leftover report.
3856    ///
3857    /// `-z` because without it git writes paths for display: anything
3858    /// non-ASCII comes back escaped and wrapped in quotes, and that string is
3859    /// not a path. A part built from one carries a pathspec matching no file,
3860    /// so the file never reaches the slice while every list still says the part
3861    /// took it. It also keeps a path with a space at either end intact.
3862    pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
3863        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3864        self.git_try_at(
3865            Some(cwd),
3866            &["diff", "--name-only", "--no-renames", "-z", &range],
3867        )
3868        .split('\0')
3869        .filter(|path| !path.is_empty())
3870        .map(str::to_string)
3871        .collect()
3872    }
3873
3874    /// The lines a line comment can go on: the new side of each hunk of the
3875    /// branch's three dot diff, which is the diff GitHub shows for a pull
3876    /// request.
3877    ///
3878    /// Every flag that shapes the hunks is spelled out as git's own default,
3879    /// which is what GitHub shows, so a user's `diff.noprefix`, `diff.context`,
3880    /// `diff.interHunkContext`, `diff.algorithm` or `diff.submodule` cannot
3881    /// move a line comment onto a line GitHub does not show. `GIT_DIFF_OPTS`
3882    /// is emptied for the same reason: git reads it after the command line,
3883    /// so it would widen every hunk past `--unified=3`. `--no-ext-diff` and
3884    /// `--no-textconv` also matter for more than the shape: the branch being
3885    /// reviewed is somebody else's, and a diff driver or text converter would
3886    /// run a script it changed. A failure is no hunks, which only means every
3887    /// finding stays in the summary.
3888    pub fn diff_hunks(&self, cwd: &Path, base: &str) -> Hunks {
3889        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3890        let argv = git_without_maintenance_argv(&[
3891            "-c",
3892            "core.quotePath=false",
3893            "diff",
3894            "--no-color",
3895            "--no-ext-diff",
3896            "--no-textconv",
3897            "--unified=3",
3898            "--inter-hunk-context=0",
3899            "--diff-algorithm=myers",
3900            "--indent-heuristic",
3901            "--submodule=short",
3902            "--find-renames",
3903            "--src-prefix=a/",
3904            "--dst-prefix=b/",
3905            &range,
3906        ]);
3907        let opts = self.git_opts(Some(cwd), false).env("GIT_DIFF_OPTS", "");
3908        new_side_hunks(&proc::run(&argv, &opts).unwrap_or_default())
3909    }
3910
3911    /// The files one commit range touched, for checking a claim against the
3912    /// diff rather than against the report that made it.
3913    pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
3914        self.git_try_at(
3915            Some(cwd),
3916            &[
3917                "diff",
3918                "--name-only",
3919                "--no-renames",
3920                "-z",
3921                &format!("{from}..{to}"),
3922            ],
3923        )
3924        .split('\0')
3925        .filter(|path| !path.is_empty())
3926        .map(str::to_string)
3927        .collect()
3928    }
3929
3930    /// Where `refname` left the base: the commit its own change is measured
3931    /// from, and the one a slice of that change has to be taken against.
3932    pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
3933        let base_ref = self.base_ref(cwd, base);
3934        let out = self
3935            .git_at(Some(cwd), &["merge-base", &base_ref, refname])
3936            .map_err(|e| {
3937                spar_err!(
3938                    "could not find where {refname} and {base_ref} diverged. {}",
3939                    e.last_line()
3940                )
3941            })?;
3942        let sha = out.trim().to_string();
3943        if sha.is_empty() {
3944            bail!("{refname} and {base_ref} share no history");
3945        }
3946        Ok(sha)
3947    }
3948
3949    pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
3950        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3951        let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
3952        full.trim().to_string()
3953    }
3954
3955    /// Scrub commit messages that slipped past the prompt.
3956    ///
3957    /// `git filter-branch` calls back into this same binary, so there is no
3958    /// interpreter to find and no second copy of the rules to drift.
3959    ///
3960    /// `floor` is the head spar found when it started, and nothing at or below
3961    /// it is rewritten. The style gate is for what spar writes, and a person's
3962    /// commit message is not that: rewriting one changes a published SHA, which
3963    /// diverges the branch under somebody's own checkout and is exactly the
3964    /// history rewrite this design promises never to do. A `None` floor means
3965    /// every commit above the base is spar's own, which is true of a branch
3966    /// spar created in this invocation and of nothing else.
3967    pub fn rewrite_commits_if_needed(
3968        &self,
3969        cwd: &Path,
3970        base: &str,
3971        floor: Option<&str>,
3972    ) -> Result<()> {
3973        let base_ref = self.base_ref(cwd, base);
3974        let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
3975        self.report_commits_left_alone(cwd, &base_ref, floor);
3976
3977        let range = format!("{floor}..HEAD");
3978        let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
3979
3980        let offenders = raw
3981            .split('\x1e')
3982            .filter_map(|entry| entry.split_once('\0'))
3983            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3984            .count();
3985        if offenders == 0 {
3986            return Ok(());
3987        }
3988        logdim!("{offenders} commit message(s) violated style rules, rewriting");
3989
3990        let exe = self_binary()?;
3991        let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
3992
3993        let argv: Vec<String> = [
3994            "git",
3995            "filter-branch",
3996            "-f",
3997            "--msg-filter",
3998            &filter,
3999            &range,
4000        ]
4001        .iter()
4002        .map(|s| s.to_string())
4003        .collect();
4004        let opts = ExecOpts::new()
4005            .cwd(cwd)
4006            .check(false)
4007            .timeout_secs(600)
4008            .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
4009            .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
4010            .env(
4011                "SPAR_BAN_AI_ATTRIBUTION",
4012                bool_env(self.style.ban_ai_attribution),
4013            );
4014        let _ = proc::run(&argv, &opts);
4015
4016        let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
4017        if !style::violations(&after, &self.style).is_empty() {
4018            bail!(
4019                "commit messages still violate style rules after a rewrite in {}.",
4020                cwd.display()
4021            );
4022        }
4023        Ok(())
4024    }
4025
4026    /// Say out loud when a message spar will not touch breaks the style rules.
4027    ///
4028    /// Silence here would read as "there was nothing to scrub", and the point
4029    /// of the floor is that there was something and spar chose to leave it.
4030    fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
4031        if floor == base_ref {
4032            return;
4033        }
4034        let raw = self.git_try_at(
4035            Some(cwd),
4036            &[
4037                "log",
4038                &format!("{base_ref}..{floor}"),
4039                "--format=%H%x00%B%x1e",
4040            ],
4041        );
4042        let left: Vec<&str> = raw
4043            .split('\x1e')
4044            .filter_map(|entry| entry.split_once('\0'))
4045            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
4046            .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
4047            .collect();
4048        if left.is_empty() {
4049            return;
4050        }
4051        logdim!(
4052            "{} commit message(s) already on the branch break the style rules and were left \
4053             alone: {}. spar does not rewrite commits it did not write.",
4054            left.len(),
4055            left.join(", ")
4056        );
4057    }
4058
4059    /// Push by explicit refspec from HEAD, leasing the head spar reviewed.
4060    ///
4061    /// A resumed PR is checked out under a local name (`pr-N`) that does not
4062    /// match its remote branch, so pushing by branch name would resolve the
4063    /// wrong local ref or fail outright.
4064    ///
4065    /// `expected` is the remote head spar last read. A bare `--force-with-lease`
4066    /// leases the implicit tracking ref instead, which is shared across every
4067    /// worktree and is updated by any `git fetch`. The editing agent is a real
4068    /// CLI with shell access, and fetching is a normal thing for it to do: one
4069    /// `git fetch` mid round moves the tracking ref to whatever the remote holds
4070    /// and the lease then passes against a head spar never reviewed, which
4071    /// silently drops somebody's commit. Naming the head makes the push refuse
4072    /// exactly that case.
4073    pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
4074        self.push_to(cwd, &PushTarget::Origin, branch, expected)
4075    }
4076
4077    /// `push`, to wherever the branch was first published.
4078    pub fn push_to(
4079        &self,
4080        cwd: &Path,
4081        target: &PushTarget,
4082        branch: &str,
4083        expected: Option<&str>,
4084    ) -> Result<()> {
4085        let expected = expected.map(str::trim).filter(|head| !head.is_empty());
4086        if let Some(head) = expected {
4087            self.refuse_push_that_drops(cwd, head)?;
4088        }
4089        let pushed = self
4090            .git_push(cwd, target, branch, expected)
4091            .map_err(|e| push_error(target, branch, &e));
4092        self.record_write(pushed)
4093    }
4094
4095    /// Publish a branch created in this invocation, from a fork if origin
4096    /// refuses it.
4097    ///
4098    /// Only a new branch can move. A pull request that already exists has its
4099    /// branch where it is, and pushing it anywhere else would not update it. A
4100    /// new one has no pull request yet, so a fork is just where it is opened
4101    /// from, which is how anybody without write access contributes.
4102    ///
4103    /// One write whichever way it goes. The refusal is not counted as a
4104    /// failure when the fork takes the branch: the run did what it set out to,
4105    /// and a failed write makes the exit status say otherwise.
4106    pub fn publish_new_branch(&self, cwd: &Path, branch: &str, base: &str) -> Result<PushTarget> {
4107        let origin = PushTarget::Origin;
4108        let refused = match self.git_push(cwd, &origin, branch, None) {
4109            Ok(()) => return self.record_write(Ok(origin)),
4110            Err(e) if self.fork_when_denied && push_denial(&e.to_string()).is_some() => e,
4111            Err(e) => {
4112                let err = push_error(&origin, branch, &e);
4113                let err = if push_denial(&e.to_string()).is_some() {
4114                    spar_err!(
4115                        "{err} Set fork_when_denied = true under [loop] to publish it from a fork \
4116                         instead."
4117                    )
4118                } else {
4119                    err
4120                };
4121                return self.record_write(Err(err));
4122            }
4123        };
4124        self.record_write(self.publish_on_fork(cwd, branch, base, &refused))
4125    }
4126
4127    fn publish_on_fork(
4128        &self,
4129        cwd: &Path,
4130        branch: &str,
4131        base: &str,
4132        refused: &crate::error::SparError,
4133    ) -> Result<PushTarget> {
4134        let fork = self.fork().map_err(|e| {
4135            spar_err!(
4136                "{} spar could not publish it from a fork instead: {}",
4137                push_error(&PushTarget::Origin, branch, refused),
4138                e.last_line()
4139            )
4140        })?;
4141        let lease = self.fork_branch_lease(&fork, branch, base)?;
4142        let refused = refused.to_string();
4143        log!(
4144            "{} Publishing {branch} from the fork {} instead.",
4145            push_denial(&refused).unwrap_or("origin refused the push."),
4146            fork.full_name
4147        );
4148        let target = PushTarget::Fork(fork);
4149        // Not `push_to`: a lease on a branch a closed pull request left behind
4150        // replaces it, and that branch was never this HEAD's to contain.
4151        self.git_push(cwd, &target, branch, lease.as_deref())
4152            .map_err(|e| push_error(&target, branch, &e))?;
4153        Ok(target)
4154    }
4155
4156    /// What the first push of `branch` to the fork leases against.
4157    ///
4158    /// Nothing when the fork has no such branch. Its tip when a pull request
4159    /// already holds every commit on it beyond `base`, which is what a closed
4160    /// pull request leaves, since GitHub keeps the branch on the fork. Anything
4161    /// else is work only that branch has, and is refused the way an issue
4162    /// branch on origin is.
4163    fn fork_branch_lease(&self, fork: &Fork, branch: &str, base: &str) -> Result<Option<String>> {
4164        let live_ref = format!("refs/heads/{branch}");
4165        let unreadable = |e: crate::error::SparError| {
4166            spar_err!(
4167                "could not check whether the fork {} already has {branch}. {}",
4168                fork.full_name,
4169                e.last_line()
4170            )
4171        };
4172        let listed = self
4173            .git(&["ls-remote", "--heads", &fork.url, &live_ref])
4174            .map_err(unreadable)?;
4175        let Some(tip) = remote_head_oid(&listed, &live_ref)? else {
4176            return Ok(None);
4177        };
4178        let local = format!("refs/spar/fork/{branch}");
4179        self.git(&[
4180            "fetch",
4181            "--no-tags",
4182            &fork.url,
4183            &format!("+{live_ref}:{local}"),
4184        ])
4185        .map_err(unreadable)?;
4186        let ahead = self.commit_count_checked(&self.root, &local, base);
4187        let held = matches!(ahead, Ok(0)) || self.pull_request_holds(branch, &local, base);
4188        self.git_try(&["update-ref", "-d", &local]);
4189        let ahead = ahead?;
4190        if !held {
4191            bail!(
4192                "the fork {} already has {branch} with {ahead} commit(s) that are not on {base}, \
4193                 and no pull request accounts for them. Publishing over it would lose that work.\n\
4194                 Delete it with `git push {} --delete {branch}` if it is no longer needed, then \
4195                 run spar again.",
4196                fork.full_name,
4197                fork.url
4198            );
4199        }
4200        Ok(Some(tip))
4201    }
4202
4203    fn git_push(
4204        &self,
4205        cwd: &Path,
4206        target: &PushTarget,
4207        branch: &str,
4208        expected: Option<&str>,
4209    ) -> Result<()> {
4210        let refspec = format!("HEAD:refs/heads/{branch}");
4211        let lease = match (expected, target) {
4212            (Some(head), _) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
4213            // A fork is pushed to by URL, so there is no tracking ref for a
4214            // bare lease to read. Leasing against nothing makes the first push
4215            // a create that never moves a branch already there.
4216            (None, PushTarget::Fork(_)) => format!("--force-with-lease=refs/heads/{branch}:"),
4217            (None, PushTarget::Origin) => "--force-with-lease".to_string(),
4218        };
4219        self.git_at(Some(cwd), &["push", &lease, target.remote(), &refspec])
4220            .map(|_| ())
4221    }
4222
4223    /// The authenticated user's fork of this repository, made if there is none.
4224    ///
4225    /// GitHub answers a request to fork with the fork that already exists, so
4226    /// asking is safe either way, but it is a write and it is only made when
4227    /// looking found nothing. A new fork is copied in the background, so it is
4228    /// waited for before anything is pushed to it.
4229    fn fork(&self) -> Result<Fork> {
4230        match self.existing_fork() {
4231            Ok(Some(fork)) => return Ok(fork),
4232            Ok(None) => {}
4233            Err(e) => logdim!("{e} Asking GitHub to fork it, which returns any fork there is."),
4234        }
4235        let answer = self
4236            .gh(&["api", "-X", "POST", "repos/{owner}/{repo}/forks"])
4237            .map_err(|e| spar_err!("could not fork this repository. {}", e.last_line()))?;
4238        let fork = fork_from_api(&answer, &self.origin_push_url())?;
4239        self.wait_for_fork(&fork)?;
4240        Ok(self.fork.get_or_init(|| fork).clone())
4241    }
4242
4243    /// The authenticated user's fork of this repository, if there is one,
4244    /// found without making one.
4245    fn existing_fork(&self) -> Result<Option<Fork>> {
4246        if let Some(fork) = self.fork.get() {
4247            return Ok(Some(fork.clone()));
4248        }
4249        let answer = self
4250            .gh(&[
4251                "api",
4252                "graphql",
4253                "-F",
4254                "owner={owner}",
4255                "-F",
4256                "name={repo}",
4257                "-f",
4258                "query=query($owner: String!, $name: String!) { repository(owner: $owner, name: \
4259                 $name) { forks(first: 1, affiliations: [OWNER]) { nodes { nameWithOwner owner { \
4260                 login } sshUrl url } } } }",
4261                "--jq",
4262                ".data.repository.forks.nodes",
4263            ])
4264            .map_err(|e| {
4265                spar_err!(
4266                    "could not look for a fork of this repository. {}",
4267                    e.last_line()
4268                )
4269            })?;
4270        let found = fork_from_listing(&answer, &self.origin_push_url())?;
4271        Ok(found.map(|fork| self.fork.get_or_init(|| fork).clone()))
4272    }
4273
4274    /// Whether GitHub says the authenticated user cannot push here. False
4275    /// when it cannot say, so an unanswered question skips the early check
4276    /// rather than guessing.
4277    fn origin_refuses_push(&self) -> bool {
4278        self.gh_try(&["api", "repos/{owner}/{repo}", "--jq", ".permissions.push"])
4279            .trim()
4280            == "false"
4281    }
4282
4283    fn origin_push_url(&self) -> String {
4284        self.git_try(&["remote", "get-url", "--push", "origin"])
4285    }
4286
4287    /// Where spar can push a pull request's branch: origin for a branch in
4288    /// this repository, the fork for one on the authenticated user's own fork,
4289    /// such as one spar opened after origin refused it, and nowhere for
4290    /// anybody else's.
4291    pub fn pr_push_target(&self, pr: &PrView) -> Option<PushTarget> {
4292        if !pr.is_cross_repository {
4293            return Some(PushTarget::Origin);
4294        }
4295        let viewer = self.viewer_login().ok()?;
4296        own_fork_target(pr, viewer, &self.origin_push_url())
4297    }
4298
4299    fn wait_for_fork(&self, fork: &Fork) -> Result<()> {
4300        const TRIES: u32 = 30;
4301        let mut tries = 1;
4302        loop {
4303            match self.git(&["ls-remote", &fork.url, "HEAD"]) {
4304                Ok(_) => return Ok(()),
4305                Err(e) if tries >= TRIES => bail!(
4306                    "the fork {} could not be reached after {} seconds. {}",
4307                    fork.full_name,
4308                    TRIES * 2,
4309                    e.last_line()
4310                ),
4311                Err(_) => {
4312                    tries += 1;
4313                    std::thread::sleep(std::time::Duration::from_secs(2));
4314                }
4315            }
4316        }
4317    }
4318
4319    /// Refuse a push whose HEAD does not contain the head spar reviewed.
4320    ///
4321    /// The lease answers the remote moving. This answers the local side moving:
4322    /// an agent that reset or rebased the worktree leaves a HEAD that would
4323    /// publish over the reviewed head rather than on top of it, and the lease
4324    /// cannot tell the difference because the remote is exactly where spar left
4325    /// it.
4326    fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
4327        let head = self.head_oid_checked(cwd)?;
4328        if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
4329            return Ok(());
4330        }
4331        bail!(
4332            "refusing to push {head} because it does not contain the reviewed head {expected}. \
4333             The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
4334             the intended commits on top of it before resuming.",
4335            cwd.display()
4336        )
4337    }
4338
4339    /// Create one remote branch for a split without ever moving an existing ref.
4340    ///
4341    /// `worktree_for_split` chooses a name that is free locally and on origin,
4342    /// but another writer can still take it before the push. An empty expected
4343    /// value in the lease makes this an atomic create: it creates an absent ref,
4344    /// accepts an identical ref as a no-op, and never moves an existing ref.
4345    /// The shared `push` method cannot be used because its lease permits
4346    /// updating a ref fetched earlier.
4347    pub fn push_split_branch(
4348        &self,
4349        cwd: &Path,
4350        branch: &str,
4351    ) -> std::result::Result<(), SplitPushError> {
4352        let remote_ref = format!("refs/heads/{branch}");
4353        let lease = format!("--force-with-lease={remote_ref}:");
4354        let refspec = format!("HEAD:{remote_ref}");
4355        let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
4356            Ok(_) => Ok(()),
4357            Err(push_error) => {
4358                let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
4359                let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
4360                reconcile_failed_split_push(branch, push_error, local, remote)
4361            }
4362        };
4363        self.record_write(result)
4364    }
4365
4366    // -- gh ---------------------------------------------------------------
4367
4368    pub fn gh(&self, args: &[&str]) -> Result<String> {
4369        self.gh_at(None, args)
4370    }
4371
4372    pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
4373        let mut argv = vec!["gh".to_string()];
4374        argv.extend(args.iter().map(|s| s.to_string()));
4375        proc::run(
4376            &argv,
4377            &ExecOpts::new()
4378                .cwd(cwd.unwrap_or(&self.root))
4379                .timeout_secs(300),
4380        )
4381    }
4382
4383    /// Run gh with something on its stdin.
4384    ///
4385    /// A tracker body is far too long to pass on argv, and `--body-file -` is
4386    /// how gh takes one. `proc::exec` already wires the pipe, so this is a
4387    /// sibling of `gh_at` rather than anything new.
4388    pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
4389        let mut argv = vec!["gh".to_string()];
4390        argv.extend(args.iter().map(|s| s.to_string()));
4391        proc::run(
4392            &argv,
4393            &ExecOpts::new()
4394                .cwd(&self.root)
4395                .timeout_secs(300)
4396                .stdin(stdin),
4397        )
4398    }
4399
4400    pub fn gh_try(&self, args: &[&str]) -> String {
4401        let mut argv = vec!["gh".to_string()];
4402        argv.extend(args.iter().map(|s| s.to_string()));
4403        proc::run(
4404            &argv,
4405            &ExecOpts::new()
4406                .cwd(&self.root)
4407                .check(false)
4408                .timeout_secs(300),
4409        )
4410        .unwrap_or_default()
4411    }
4412
4413    /// `gh api -i`: the status line and headers with the body, and the exit
4414    /// code ignored on purpose. A `304 Not Modified`, which is the answer a
4415    /// conditional poll wants, makes gh exit 1 while printing exactly what is
4416    /// needed. Only a spawn failure or a timeout is an error here.
4417    pub fn gh_api_raw(&self, endpoint: &str, headers: &[(&str, &str)]) -> Result<ApiResponse> {
4418        let mut argv = vec!["gh".to_string(), "api".to_string(), "-i".to_string()];
4419        for (name, value) in headers {
4420            argv.push("-H".to_string());
4421            argv.push(format!("{name}: {value}"));
4422        }
4423        argv.push(endpoint.to_string());
4424        let out = proc::exec(
4425            &argv,
4426            &ExecOpts::new()
4427                .cwd(&self.root)
4428                .check(false)
4429                .timeout_secs(300),
4430        )?;
4431        parse_api_response(&out.stdout)
4432            .ok_or_else(|| crate::error::SparError::call_failed(proc::failure_message(&argv, &out)))
4433    }
4434
4435    /// Every open pull request, with what a sweep needs to know about each.
4436    pub fn sweep_open_prs(&self) -> Result<Vec<PrSweepRow>> {
4437        let text = self.gh(&[
4438            "pr",
4439            "list",
4440            "--state",
4441            "open",
4442            "--limit",
4443            "500",
4444            "--json",
4445            PR_SWEEP_FIELDS,
4446        ])?;
4447        serde_json::from_str(&text)
4448            .map_err(|e| spar_err!("unexpected shape for the pull request list: {e}"))
4449    }
4450
4451    /// One pull request, open or not, with what a sweep needs to know.
4452    pub fn sweep_pr(&self, number: i64) -> Result<PrSweepRow> {
4453        let text = self.gh(&["pr", "view", &number.to_string(), "--json", PR_SWEEP_FIELDS])?;
4454        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4455    }
4456
4457    /// Whether `login` is in a team, named `org/slug` as gh gives a team in
4458    /// `reviewRequests`. GitHub answers 404 for a team the login is not in, and
4459    /// a team the viewer is in is one it can always see. None when GitHub did
4460    /// not answer either way.
4461    pub fn team_has_member(&self, team: &str, login: &str) -> Option<bool> {
4462        // A team asked for a review belongs to the repository's own owner.
4463        let (org, slug) = team.split_once('/').unwrap_or(("{owner}", team));
4464        if slug.is_empty() || login.is_empty() {
4465            return None;
4466        }
4467        let path = format!("orgs/{org}/teams/{slug}/memberships/{login}");
4468        let resp = self.gh_api_raw(&path, &[]).ok()?;
4469        match resp.status {
4470            200 => {
4471                let body: Value = serde_json::from_str(&resp.body).ok()?;
4472                Some(body.get("state")?.as_str()? == "active")
4473            }
4474            404 => Some(false),
4475            _ => None,
4476        }
4477    }
4478
4479    /// Every review request made on a pull request, oldest first. None when
4480    /// GitHub did not answer.
4481    ///
4482    /// A review spar posts as a comment leaves the request it answered open,
4483    /// so `reviewRequests` alone cannot tell that request from one made after
4484    /// the review. When it was made can.
4485    pub fn review_requests_made(&self, number: i64) -> Option<Vec<crate::model::ReviewRequested>> {
4486        let text = self
4487            .gh(&[
4488                "api",
4489                "graphql",
4490                "-F",
4491                "owner={owner}",
4492                "-F",
4493                "repo={repo}",
4494                "-F",
4495                &format!("number={number}"),
4496                "-f",
4497                &format!("query={REQUESTS_QUERY}"),
4498            ])
4499            .ok()?;
4500        parse_review_requests(&text)
4501    }
4502
4503    /// How many commits lie between two SHAs, when GitHub will say.
4504    pub fn commits_between(&self, from: &str, to: &str) -> Option<u64> {
4505        if from.is_empty() || to.is_empty() {
4506            return None;
4507        }
4508        let path = format!("repos/{{owner}}/{{repo}}/compare/{from}...{to}");
4509        self.gh_try(&["api", &path, "--jq", ".total_commits"])
4510            .trim()
4511            .parse()
4512            .ok()
4513    }
4514
4515    /// `owner/name`, for the line that says what is being watched.
4516    pub fn repo_name(&self) -> Option<String> {
4517        let name = self.gh_try(&[
4518            "repo",
4519            "view",
4520            "--json",
4521            "nameWithOwner",
4522            "--jq",
4523            ".nameWithOwner",
4524        ]);
4525        let name = name.trim();
4526        (!name.is_empty()).then(|| name.to_string())
4527    }
4528
4529    pub fn listen_state_path(&self) -> PathBuf {
4530        self.root.join(STATE_DIR).join("state").join("listen.json")
4531    }
4532
4533    pub fn listen_lock_path(&self) -> PathBuf {
4534        self.root.join(STATE_DIR).join("state").join("listen.lock")
4535    }
4536
4537    /// The login `gh` is authenticated as.
4538    ///
4539    /// A hard error, never a degradation. Everything spar wrote has to be
4540    /// excluded from what it answers, and custody cannot be read from git
4541    /// authorship, so this is the only thing that tells spar's own comments
4542    /// from somebody else's. Without it the failure is not "answers a bit too
4543    /// much", it is a thread where spar answers itself until somebody notices.
4544    ///
4545    /// Not cached on disk: `gh auth switch` between runs would make a stored
4546    /// answer wrong in exactly the way that produces that thread.
4547    pub fn viewer_login(&self) -> Result<&str> {
4548        if let Some(login) = self.viewer.get() {
4549            return Ok(login);
4550        }
4551        let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
4552        let login = if !rest.trim().is_empty() {
4553            rest.trim().to_string()
4554        } else {
4555            // A token that cannot read /user can still answer for itself in
4556            // GraphQL, which is the case on some Enterprise installs.
4557            self.gh(&[
4558                "api",
4559                "graphql",
4560                "-f",
4561                "query={ viewer { login } }",
4562                "--jq",
4563                ".data.viewer.login",
4564            ])
4565            .map_err(|e| {
4566                spar_err!(
4567                    "could not find out who `gh` is authenticated as, so spar cannot tell its \
4568                     own comments from anybody else's. {}\nRun `gh auth status`.",
4569                    e.last_line()
4570                )
4571            })?
4572            .trim()
4573            .to_string()
4574        };
4575        if login.is_empty() {
4576            bail!("`gh` reported an empty login. Run `gh auth status`.");
4577        }
4578        Ok(self.viewer.get_or_init(|| login))
4579    }
4580
4581    /// One issue as it stands, open or closed.
4582    ///
4583    /// `fetch_issues` reads a queue to work: it drops a closed issue and fails
4584    /// when nothing survives. Both are wrong for reading one issue back, where
4585    /// closed is an answer and the empty case cannot arise.
4586    pub fn read_issue(&self, number: i64) -> Result<Issue> {
4587        let text = self
4588            .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4589            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4590        serde_json::from_str(&text)
4591            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
4592    }
4593
4594    pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
4595        let mut issues = Vec::new();
4596        for number in numbers {
4597            let text = self
4598                .gh(&["issue", "view", &number.to_string(), "--json", ISSUE_FIELDS])
4599                .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
4600            let issue: Issue = serde_json::from_str(&text)
4601                .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
4602            if issue.is_closed() {
4603                crate::log!("issue #{number} is closed, skipping");
4604                continue;
4605            }
4606            issues.push(issue);
4607        }
4608        if issues.is_empty() {
4609            bail!("no open issues to work on");
4610        }
4611        Ok(issues)
4612    }
4613
4614    /// Open items, lowest numbered first, from `min_number` upward.
4615    ///
4616    /// The floor exists because a long lived repository accumulates a tail of
4617    /// old issues nobody is going to get to, and taking the lowest numbered
4618    /// open items means walking straight into them.
4619    fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4620        #[derive(Deserialize)]
4621        struct Row {
4622            number: i64,
4623        }
4624        let text = self.gh(&[
4625            kind,
4626            "list",
4627            "--state",
4628            "open",
4629            "--limit",
4630            &FETCH_CEILING.to_string(),
4631            "--json",
4632            "number",
4633        ])?;
4634        let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4635        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
4636        numbers.sort_unstable();
4637
4638        let noun = if kind == "issue" { "issues" } else { "PRs" };
4639        let found = numbers.len();
4640        if min_number > 0 {
4641            numbers.retain(|n| *n >= min_number);
4642            let skipped = found - numbers.len();
4643            if skipped > 0 {
4644                crate::log!("{skipped} open {noun} below #{min_number} skipped");
4645            }
4646        }
4647        if found >= FETCH_CEILING {
4648            crate::log!(
4649                "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
4650                 considered."
4651            );
4652        }
4653        if numbers.len() > limit {
4654            crate::log!(
4655                "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
4656                 explicitly.",
4657                numbers.len()
4658            );
4659            numbers.truncate(limit);
4660        }
4661        Ok(numbers)
4662    }
4663
4664    /// Open issues, lowest numbered first. `gh issue list` excludes PRs.
4665    pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4666        self.open_numbers("issue", limit, min_number)
4667    }
4668
4669    pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
4670        self.open_numbers("pr", limit, min_number)
4671    }
4672
4673    pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
4674        self.branch_prs(branch, "open").into_iter().next()
4675    }
4676
4677    /// The open pull request for a branch, preserving a failed lookup as an
4678    /// error when the caller is deciding whether a write already landed.
4679    pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
4680        let text = self.gh(&[
4681            "pr",
4682            "list",
4683            "--head",
4684            branch,
4685            "--base",
4686            base,
4687            "--state",
4688            "open",
4689            "--json",
4690            "number,url,title,baseRefName",
4691        ])?;
4692        pr_for_base(&text, branch, base)
4693    }
4694
4695    /// Every pull request opened from this branch, merged and closed ones
4696    /// included, because a commit is preserved by whichever one carries it and
4697    /// that is rarely the newest.
4698    fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
4699        self.branch_prs(branch, "all")
4700    }
4701
4702    fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
4703        let text = self.gh_try(&[
4704            "pr",
4705            "list",
4706            "--head",
4707            branch,
4708            "--state",
4709            state,
4710            "--json",
4711            "number,url,title",
4712        ]);
4713        serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
4714    }
4715
4716    /// Whether a number names an issue or a pull request.
4717    ///
4718    /// `gh issue view` happily returns a pull request when handed its number,
4719    /// so it cannot be used to tell them apart. The issues API carries both and
4720    /// marks a pull request with a `pull_request` key, which is definitive.
4721    pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
4722        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
4723        let text = self
4724            .gh(&[
4725                "api",
4726                &path,
4727                "--jq",
4728                "if .pull_request then \"pr\" else \"issue\" end",
4729            ])
4730            .map_err(|e| {
4731                spar_err!(
4732                    "no issue or pull request #{number} in this repository. {}",
4733                    e.last_line()
4734                )
4735            })?;
4736        match text.trim() {
4737            "pr" => Ok(ItemKind::Pr),
4738            "issue" => Ok(ItemKind::Issue),
4739            other => Err(spar_err!(
4740                "could not tell whether #{number} is an issue or a pull request (got {other:?})"
4741            )),
4742        }
4743    }
4744
4745    /// An open pull request that would close this issue, whoever opened it.
4746    ///
4747    /// spar's own branch naming is checked first because it is exact and cheap.
4748    /// Falling back to GitHub's own issue linkage is what lets spar pick up a
4749    /// pull request a person started on a branch named anything at all.
4750    ///
4751    /// An error rather than `None` when the question could not be asked. The
4752    /// two answers are not interchangeable: "no pull request exists" is the one
4753    /// that makes spar implement over the top of somebody's work and open a
4754    /// second pull request for the same issue.
4755    pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
4756        let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
4757        if let Some(pr) = mine {
4758            return Ok(Some(pr));
4759        }
4760        let listed = match self.gh(&[
4761            "pr",
4762            "list",
4763            "--state",
4764            "open",
4765            "--limit",
4766            &FETCH_CEILING.to_string(),
4767            "--json",
4768            "number,url,title,baseRefName,closingIssuesReferences",
4769        ]) {
4770            Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
4771                spar_err!("could not read the pull request list while looking for #{issue}")
4772            })?,
4773            Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
4774        };
4775        if listed.len() >= FETCH_CEILING {
4776            logwarn!(
4777                "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a                  pull request past it cannot be seen from here."
4778            );
4779        }
4780        let default = self.default_branch(base);
4781        if !base.eq_ignore_ascii_case(&default) {
4782            // GitHub populates closingIssuesReferences only for pull requests
4783            // targeting the default branch, so on any other base the linkage is
4784            // silence rather than an answer.
4785            logwarn!(
4786                "#{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."
4787            );
4788        }
4789        Ok(pick_linked_pr(&listed, issue))
4790    }
4791
4792    /// spar's own branch, with a failed lookup kept as a failure.
4793    fn pr_for_branch_failing_closed(
4794        &self,
4795        branch: &str,
4796        base: &str,
4797        issue: i64,
4798    ) -> Result<Option<PrRef>> {
4799        match self.try_pr_for_branch(branch, base) {
4800            Ok(found) => Ok(found),
4801            Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
4802        }
4803    }
4804
4805    /// What to do when GitHub would not say whether a pull request exists.
4806    ///
4807    /// An error, because "no pull request exists" is the answer that makes spar
4808    /// implement from scratch and open a second pull request over somebody's
4809    /// work. The one exception is a repository gh cannot reach at all: there is
4810    /// no pull request to collide with when there is no GitHub repository in
4811    /// play, and every later write would fail anyway. That case is a warning and
4812    /// a `None`, so a run against a plain git remote behaves as it always did.
4813    fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
4814        if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
4815            return Err(spar_err!(
4816                "could not check whether a pull request already exists for #{issue}: {}. \
4817                 Refusing to implement over the top of work that may already be open.",
4818                e.last_line()
4819            ));
4820        }
4821        logwarn!(
4822            "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
4823             whether a pull request already exists for it.",
4824            e.last_line()
4825        );
4826        Ok(())
4827    }
4828
4829    pub fn pr_view(&self, number: i64) -> Result<PrView> {
4830        let text = self.gh(&[
4831            "pr",
4832            "view",
4833            &number.to_string(),
4834            "--json",
4835            "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,\
4836             isCrossRepository,headRepositoryOwner,headRepository",
4837        ])?;
4838        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
4839    }
4840
4841    fn try_pr_state(&self, number: i64) -> Result<String> {
4842        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
4843        serde_json::from_str::<Value>(text.trim())
4844            .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
4845            .get("state")
4846            .and_then(Value::as_str)
4847            .map(str::to_string)
4848            .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
4849    }
4850
4851    pub fn pr_state(&self, number: i64) -> String {
4852        self.try_pr_state(number).unwrap_or_default()
4853    }
4854
4855    /// The commit currently exposed as a pull request's head.
4856    pub fn pr_head_oid(&self, number: i64) -> Result<String> {
4857        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
4858        let oid = serde_json::from_str::<Value>(&text)
4859            .ok()
4860            .and_then(|value| {
4861                value
4862                    .get("headRefOid")
4863                    .and_then(Value::as_str)
4864                    .map(str::trim)
4865                    .filter(|oid| !oid.is_empty())
4866                    .map(str::to_string)
4867            })
4868            .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
4869        Ok(oid)
4870    }
4871
4872    /// Where a pull request's head stands now: what GitHub's pull request
4873    /// names, and, for a branch in this repository, where `origin` has the
4874    /// branch. The two differ until GitHub catches up with a push.
4875    pub fn live_head(&self, pr: &PrView) -> Result<LiveHead> {
4876        let pull = self.pr_head_oid(pr.number)?;
4877        let branch = if pr.is_cross_repository || pr.head_ref_name.trim().is_empty() {
4878            None
4879        } else {
4880            let remote_ref = format!("refs/heads/{}", pr.head_ref_name.trim());
4881            let listed = self
4882                .git(&["ls-remote", "--heads", "origin", &remote_ref])
4883                .map_err(|e| {
4884                    spar_err!(
4885                        "could not read where {} is on origin: {}",
4886                        pr.head_ref_name.trim(),
4887                        e.last_line()
4888                    )
4889                })?;
4890            remote_head_oid(&listed, &remote_ref)?
4891        };
4892        Ok(LiveHead { pull, branch })
4893    }
4894
4895    /// Open a pull request from `branch`, published at `target`.
4896    ///
4897    /// The lookup that confirms it landed goes by branch name alone, which
4898    /// `gh pr list --head` matches on a fork's pull request too.
4899    pub fn create_pr(
4900        &self,
4901        cwd: &Path,
4902        branch: &str,
4903        target: &PushTarget,
4904        base: &str,
4905        title: &str,
4906        body: &str,
4907    ) -> Result<PrRef> {
4908        let title = self.record_failed_write(self.clean_title(title))?;
4909        let body = self.record_failed_write(self.clean(body))?;
4910        let head = target.head(branch);
4911        let mut argv = vec![
4912            "pr", "create", "--base", base, "--head", &head, "--title", &title, "--body", &body,
4913        ];
4914        if self.drafts != Drafts::Never {
4915            argv.push("--draft");
4916        }
4917        let created = self.gh_at(Some(cwd), &argv);
4918        let found = self.try_pr_for_branch(branch, base);
4919        self.record_write(reconcile_pr_creation(branch, created, found))
4920    }
4921
4922    pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
4923        let body = signed(&self.record_failed_write(self.clean(body))?);
4924        let comments = self.record_failed_write(self.try_issue_comments(number))?;
4925        if has_exact_comment(&comments, &body) {
4926            return Ok(());
4927        }
4928        let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
4929        let result = match posted {
4930            Ok(_) => Ok(()),
4931            Err(post_error) => {
4932                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
4933            }
4934        };
4935        self.record_write(result)
4936    }
4937
4938    /// Whether the viewer wrote this pull request. GitHub refuses to let an
4939    /// author approve or request changes on their own, so a verdict on one can
4940    /// only ever be a comment.
4941    fn pr_is_own(&self, number: i64) -> Result<bool> {
4942        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "author"])?;
4943        let author = serde_json::from_str::<Value>(&text)
4944            .ok()
4945            .and_then(|v| v.get("author")?.get("login")?.as_str().map(str::to_string))
4946            .unwrap_or_default();
4947        let viewer = self.viewer_login()?.to_string();
4948        Ok(!author.is_empty() && crate::comments::same_login(&author, &viewer))
4949    }
4950
4951    /// Submit a formal review, or a comment carrying the verdict's words when
4952    /// GitHub will not take the verdict: the viewer's own pull request, or no
4953    /// verdict at all.
4954    ///
4955    /// Mirrors `comment_pr` step for step: the style gate, the signature, a
4956    /// duplicate check against the viewer's own reviews, the write, and a read
4957    /// back on failure so an unverifiable write is reported as one.
4958    ///
4959    /// `commit` is the commit the reviewers read. GitHub attaches a review
4960    /// that names no commit to whatever the head is when it arrives, so a push
4961    /// made while a review ran would otherwise receive an approval nobody
4962    /// gave it. `gh pr review` cannot name one, hence the REST call.
4963    ///
4964    /// `comments` go on their lines in the same review, so they arrive with
4965    /// it or not at all. GitHub refuses the whole review over one line its diff
4966    /// does not show, so when it refused this one and the read back finds
4967    /// nothing landed, the review goes up again without them: the body already
4968    /// says everything they do. Any other failure is reconciled as a review
4969    /// without them would be, never retried: a gateway error can arrive while
4970    /// GitHub is still saving a review that will land.
4971    pub fn submit_review(
4972        &self,
4973        number: i64,
4974        verdict: Option<crate::model::ReviewVerdict>,
4975        body: &str,
4976        commit: &str,
4977        comments: &[InlineComment],
4978    ) -> Result<()> {
4979        let own = match verdict {
4980            Some(_) => self.record_failed_write(self.pr_is_own(number))?,
4981            None => false,
4982        };
4983        let text = match (verdict, own) {
4984            (Some(v), true) => format!("Verdict: {}.\n\n{body}", v.words()),
4985            _ => body.to_string(),
4986        };
4987        let body = signed(&self.record_failed_write(self.clean(&text))?);
4988        let mut inline = Vec::with_capacity(comments.len());
4989        for c in comments {
4990            inline.push(InlineComment {
4991                body: signed(&self.record_failed_write(self.clean(&c.body))?),
4992                ..c.clone()
4993            });
4994        }
4995        let viewer = self.record_failed_write(self.viewer_login())?.to_string();
4996        let reviews = self.record_failed_write(self.try_pr_reviews(number))?;
4997        if has_viewer_review(&reviews, &viewer, &body, commit) {
4998            return Ok(());
4999        }
5000        let event = match (verdict, own) {
5001            (Some(v), false) => v.api_event(),
5002            _ => "COMMENT",
5003        };
5004        let result = if inline.is_empty() {
5005            self.post_review(number, &body, &viewer, commit, event)
5006        } else {
5007            let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5008            let payload = review_payload(commit, event, &body, &inline).to_string();
5009            match self.gh_stdin(
5010                &["api", "-X", "POST", &path, "--input", "-", "--silent"],
5011                &payload,
5012            ) {
5013                Ok(_) => Ok(()),
5014                Err(post_error) if !refused(&post_error) => reconcile_review_post(
5015                    number,
5016                    &body,
5017                    &viewer,
5018                    commit,
5019                    post_error,
5020                    self.try_pr_reviews(number),
5021                ),
5022                Err(post_error) => match self.try_pr_reviews(number) {
5023                    Ok(reviews) if !has_viewer_review(&reviews, &viewer, &body, commit) => {
5024                        logwarn!(
5025                            "the review of #{number} with its line comments did not go up ({}), \
5026                             so it is going up with every finding in its body only",
5027                            post_error.last_line()
5028                        );
5029                        self.post_review(number, &body, &viewer, commit, event)
5030                    }
5031                    reviews => {
5032                        reconcile_review_post(number, &body, &viewer, commit, post_error, reviews)
5033                    }
5034                },
5035            }
5036        };
5037        self.record_write(result)
5038    }
5039
5040    /// A review with its body alone, read back if the post reports a failure.
5041    fn post_review(
5042        &self,
5043        number: i64,
5044        body: &str,
5045        viewer: &str,
5046        commit: &str,
5047        event: &str,
5048    ) -> Result<()> {
5049        let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/reviews");
5050        let posted = self.gh(&[
5051            "api",
5052            "-X",
5053            "POST",
5054            &path,
5055            "-f",
5056            &format!("commit_id={commit}"),
5057            "-f",
5058            &format!("event={event}"),
5059            "-f",
5060            &format!("body={body}"),
5061            "--silent",
5062        ]);
5063        match posted {
5064            Ok(_) => Ok(()),
5065            Err(post_error) => reconcile_review_post(
5066                number,
5067                body,
5068                viewer,
5069                commit,
5070                post_error,
5071                self.try_pr_reviews(number),
5072            ),
5073        }
5074    }
5075
5076    pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
5077        let body = signed(&self.record_failed_write(self.clean(body))?);
5078        let comments = self.record_failed_write(self.try_issue_comments(number))?;
5079        if has_exact_comment(&comments, &body) {
5080            return Ok(());
5081        }
5082        let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
5083        let result = match posted {
5084            Ok(_) => Ok(()),
5085            Err(post_error) => {
5086                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
5087            }
5088        };
5089        self.record_write(result)
5090    }
5091
5092    /// Comment, then close as not planned.
5093    ///
5094    /// Only ever called when both agents independently declined the issue: one
5095    /// agent's opinion is not enough to close somebody's report.
5096    pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
5097        self.comment_issue(number, body)?;
5098        let n = number.to_string();
5099        let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
5100            Ok(_) => Ok(()),
5101            // Older gh builds do not take --reason.
5102            Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
5103                spar_err!(
5104                    "commented on #{number} but could not close it: {}",
5105                    e.last_line()
5106                )
5107            }),
5108        };
5109        self.record_write(closed)
5110    }
5111
5112    /// Replace an issue body, refusing unless it is still byte for byte what
5113    /// the caller read and validating only the fragment spar inserted.
5114    ///
5115    /// The only place spar rewrites text somebody else wrote, so the check is
5116    /// the whole point: an edit computed from a body that has since moved would
5117    /// silently delete whatever moved it. The caller decides whether another
5118    /// attempt is safe for its workflow.
5119    ///
5120    /// Deliberately not through `clean_issue_body`. The body is mostly a
5121    /// person's own prose, and the scrub would rewrite their punctuation while
5122    /// the length budget could truncate the end of a long report. `inserted` is
5123    /// the only text here spar is answerable for, so it still passes through the
5124    /// style gate. The full body travels over stdin because a tracker can be far
5125    /// too long for one argument.
5126    pub fn edit_issue_body(
5127        &self,
5128        number: i64,
5129        expected: &str,
5130        body: &str,
5131        inserted: &str,
5132    ) -> Result<()> {
5133        let cleaned = self.record_failed_write(self.clean(inserted))?;
5134        if cleaned.trim() != inserted.trim() {
5135            return self.record_failed_write(Err(spar_err!(
5136                "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
5137            )));
5138        }
5139        let current = self.record_failed_write(self.issue_body(number))?;
5140        if current != expected {
5141            return self.record_failed_write(Err(spar_err!(
5142                "the body of #{number} changed since it was read, so it was left alone rather \
5143                 than written over."
5144            )));
5145        }
5146        let edited = self.gh_stdin(
5147            &["issue", "edit", &number.to_string(), "--body-file", "-"],
5148            body,
5149        );
5150        let result = match edited {
5151            Ok(_) => Ok(()),
5152            Err(edit_error) => {
5153                reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
5154            }
5155        };
5156        self.record_write(result)
5157    }
5158
5159    /// One issue's body, exactly as GitHub holds it.
5160    pub fn issue_body(&self, number: i64) -> Result<String> {
5161        #[derive(Deserialize)]
5162        struct Row {
5163            #[serde(default)]
5164            body: Option<String>,
5165        }
5166        let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
5167        let row: Row = serde_json::from_str(text.trim())
5168            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
5169        Ok(row.body.unwrap_or_default())
5170    }
5171
5172    /// Every open issue with its title and body, in one call.
5173    ///
5174    /// For a screen that has to say something about each of twenty items before
5175    /// anything expensive happens. One call rather than one per issue.
5176    pub fn open_issue_rows(&self) -> Vec<Issue> {
5177        let text = self.gh_try(&[
5178            "issue",
5179            "list",
5180            "--state",
5181            "open",
5182            "--limit",
5183            &FETCH_CEILING.to_string(),
5184            "--json",
5185            "number,title,body,labels,state,stateReason,url",
5186        ]);
5187        serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
5188    }
5189
5190    /// Every open pull request with its size, in one call.
5191    pub fn open_pr_rows(&self) -> Vec<PrRow> {
5192        let text = self.gh_try(&[
5193            "pr",
5194            "list",
5195            "--state",
5196            "open",
5197            "--limit",
5198            &FETCH_CEILING.to_string(),
5199            "--json",
5200            "number,title,changedFiles,additions,deletions",
5201        ]);
5202        serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
5203    }
5204
5205    pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
5206        self.create_issue_apart_from(title, body, None)
5207    }
5208
5209    pub fn create_issue_apart_from(
5210        &self,
5211        title: &str,
5212        body: &str,
5213        apart_from: Option<i64>,
5214    ) -> Result<String> {
5215        let title = self.record_failed_write(self.clean_title(title))?;
5216        let body = self.record_failed_write(self.clean_issue_body(body))?;
5217        let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
5218        let result = match created {
5219            Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
5220            created => {
5221                let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
5222                reconcile_issue_creation(&title, created, found)
5223            }
5224        };
5225        self.record_write(result)
5226    }
5227}
5228
5229/// An issue that already covers what spar was about to file.
5230#[derive(Debug, Clone)]
5231pub struct ExistingIssue {
5232    pub number: i64,
5233    pub url: String,
5234    pub title: String,
5235    pub body: String,
5236    pub open: bool,
5237}
5238
5239impl Repo {
5240    pub(crate) fn try_exact_issue_apart_from(
5241        &self,
5242        title: &str,
5243        body: &str,
5244        apart_from: Option<i64>,
5245    ) -> Result<Option<ExistingIssue>> {
5246        #[derive(Deserialize)]
5247        #[serde(rename_all = "camelCase")]
5248        struct Row {
5249            number: i64,
5250            #[serde(default)]
5251            title: String,
5252            #[serde(default)]
5253            url: String,
5254            #[serde(default)]
5255            body: Option<String>,
5256            #[serde(default)]
5257            state: String,
5258        }
5259
5260        let text = self.gh(&[
5261            "issue",
5262            "list",
5263            "--state",
5264            "all",
5265            "--limit",
5266            "100",
5267            "--json",
5268            "number,title,url,body,state,stateReason",
5269        ])?;
5270        let rows = serde_json::from_str::<Vec<Row>>(text.trim())
5271            .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
5272        Ok(rows
5273            .into_iter()
5274            .filter(|row| Some(row.number) != apart_from)
5275            .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
5276            .map(|row| ExistingIssue {
5277                number: row.number,
5278                url: row.url,
5279                title: row.title,
5280                body: row.body.unwrap_or_default(),
5281                open: row.state.eq_ignore_ascii_case("open"),
5282            }))
5283    }
5284
5285    /// An issue that already describes this defect, however it was worded.
5286    ///
5287    /// Exact title matching let duplicates through: two agents, or two runs a
5288    /// week apart, never word one defect identically. A real run filed two
5289    /// duplicates that way, and each had to be closed by hand afterwards.
5290    /// Titles alone are too thin to match on, so this compares titles and
5291    /// bodies together.
5292    pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
5293        self.find_similar_issue_apart_from(title, body, None)
5294    }
5295
5296    /// The same search, with one issue that cannot be its own duplicate.
5297    ///
5298    /// A tracker's body quotes every item in its checklist, so searching for an
5299    /// item's words matches the tracker before it matches anything else. That
5300    /// would link an item to the issue it is written in.
5301    /// The search a tracker runs, which wants closed issues too: a checklist
5302    /// item matching a closed issue is a done item that wants a link.
5303    pub fn find_similar_issue_apart_from(
5304        &self,
5305        title: &str,
5306        body: &str,
5307        apart_from: Option<i64>,
5308    ) -> Option<ExistingIssue> {
5309        self.try_similar_issue(title, body, apart_from, true)
5310            .ok()
5311            .flatten()
5312    }
5313
5314    /// The search a filing runs, over open issues only.
5315    pub fn try_find_similar_issue_apart_from(
5316        &self,
5317        title: &str,
5318        body: &str,
5319        apart_from: Option<i64>,
5320    ) -> Result<Option<ExistingIssue>> {
5321        self.try_similar_issue(title, body, apart_from, false)
5322    }
5323
5324    /// The same search, preserving lookup failure for a caller about to write.
5325    fn try_similar_issue(
5326        &self,
5327        title: &str,
5328        body: &str,
5329        apart_from: Option<i64>,
5330        include_closed: bool,
5331    ) -> Result<Option<ExistingIssue>> {
5332        #[derive(Deserialize)]
5333        #[serde(rename_all = "camelCase")]
5334        struct Row {
5335            number: i64,
5336            #[serde(default)]
5337            title: String,
5338            #[serde(default)]
5339            url: String,
5340            #[serde(default)]
5341            body: String,
5342            #[serde(default)]
5343            state: String,
5344        }
5345        if title.trim().is_empty() {
5346            return Ok(None);
5347        }
5348        // Search on the title's own words: GitHub's index is the cheap way to
5349        // narrow the field before comparing properly.
5350        let query: String = title
5351            .chars()
5352            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5353            .take(120)
5354            .collect();
5355        // Open issues only when something is about to be filed. A fuzzy match
5356        // against a closed one is the case worth keeping rather than dropping:
5357        // a defect that was fixed, closed, and has come back is exactly the
5358        // finding a maintainer wants, and "#N covers it and is closed" wrote it
5359        // nowhere. An exact match is still checked against every state, since
5360        // that is a regression of one specific issue rather than a resemblance
5361        // to one, and a tracker still searches closed issues because a
5362        // checklist item matching one is a done item that wants a link.
5363        let state = if include_closed { "all" } else { "open" };
5364        let text = self.gh(&[
5365            "issue",
5366            "list",
5367            "--state",
5368            state,
5369            "--limit",
5370            "100",
5371            "--search",
5372            query.trim(),
5373            "--json",
5374            "number,title,url,body,state,stateReason",
5375        ])?;
5376        let rows: Vec<Row> = serde_json::from_str(text.trim())
5377            .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
5378
5379        Ok(rows
5380            .into_iter()
5381            .filter(|row| Some(row.number) != apart_from)
5382            .find(|row| {
5383                row.title.trim().eq_ignore_ascii_case(title.trim())
5384                    || textsim::covers(title, body, &row.title, &row.body)
5385            })
5386            .map(|row| ExistingIssue {
5387                number: row.number,
5388                url: row.url,
5389                title: row.title,
5390                open: row.state.eq_ignore_ascii_case("open"),
5391                body: row.body,
5392            }))
5393    }
5394
5395    /// Avoid filing a duplicate when a follow-up already exists.
5396    pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
5397        #[derive(Deserialize)]
5398        struct Row {
5399            title: String,
5400            url: String,
5401        }
5402        let needle = title.trim().to_lowercase();
5403        if needle.is_empty() {
5404            return None;
5405        }
5406        // Quotes and newlines would be read as search syntax rather than text.
5407        let query: String = title
5408            .chars()
5409            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
5410            .take(120)
5411            .collect();
5412        let text = self.gh_try(&[
5413            "issue",
5414            "list",
5415            "--state",
5416            "all",
5417            "--limit",
5418            "100",
5419            "--search",
5420            query.trim(),
5421            "--json",
5422            "number,title,url",
5423        ]);
5424        serde_json::from_str::<Vec<Row>>(text.trim())
5425            .ok()?
5426            .into_iter()
5427            .find(|row| row.title.trim().to_lowercase() == needle)
5428            .map(|row| row.url)
5429    }
5430
5431    /// Squash merge, tolerating cleanup failures after a successful merge.
5432    ///
5433    /// Take a pull request out of draft, once the review has converged.
5434    ///
5435    /// Best effort for the remaining workflow. A failure does not discard the
5436    /// review or stop later independent work, but the final write summary
5437    /// reports it and the command returns non-zero.
5438    pub fn mark_ready(&self, number: i64) -> bool {
5439        match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
5440            Ok(_) => true,
5441            Err(e) => {
5442                logdim!(
5443                    "PR #{number} is approved but could not be taken out of draft: {}",
5444                    e.last_line()
5445                );
5446                false
5447            }
5448        }
5449    }
5450
5451    /// The worktree that has `branch` checked out, if one does.
5452    fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
5453        let listed = self
5454            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
5455            .ok()?;
5456        let wanted = format!("branch refs/heads/{branch}");
5457        let mut current: Option<PathBuf> = None;
5458        for line in listed.lines() {
5459            if let Some(path) = line.strip_prefix("worktree ") {
5460                current = Some(PathBuf::from(path));
5461            } else if line == wanted {
5462                return current;
5463            }
5464        }
5465        None
5466    }
5467
5468    /// Finish the local cleanup a merge could not.
5469    ///
5470    /// `gh pr merge --delete-branch` gives up on the local branch whenever any
5471    /// worktree has it checked out, which is the ordinary state rather than an
5472    /// odd one: `spar run` opens the pull request from
5473    /// `.spar-worktrees/issue-N` and leaves the checkout there, and the merge
5474    /// usually happens in a later `spar resume` working somewhere else. The
5475    /// next run's sweep tidied it, so nothing was ever lost, but the run that
5476    /// did the merge reported a failure about a branch nobody will touch again
5477    /// and left a checkout for the next run to explain.
5478    ///
5479    /// Held to the same rules the sweep uses, because they are the rules that
5480    /// decide whether anything would be lost: a branch spar recorded creating,
5481    /// a worktree of spar's own with no recoverable work in it, and a tip some
5482    /// surviving ref preserves. Anything else is left exactly where it is.
5483    fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
5484        let branch = self.pr_view(number).ok()?.head_ref_name;
5485        if branch.is_empty() || !self.known_branches().contains_key(&branch) {
5486            return None;
5487        }
5488        let mut released = None;
5489        if let Some(path) = self.worktree_holding(&branch) {
5490            if !path.starts_with(self.root.join(WORKTREE_DIR)) {
5491                return None;
5492            }
5493            if !matches!(
5494                self.has_recoverable_work(&path, Disposable::BuildOutput),
5495                Ok(false)
5496            ) {
5497                return None;
5498            }
5499            match self.remove_worktree_at(&path) {
5500                Ok(true) => released = Some(path),
5501                _ => return None,
5502            }
5503        }
5504        match self.delete_branch_if_safe(&branch) {
5505            Ok(true) => {
5506                self.forget_branch(&branch);
5507                Some(match released {
5508                    Some(path) => format!(
5509                        " The worktree at {} was released and branch {branch} deleted.",
5510                        path.display()
5511                    ),
5512                    None => format!(" Branch {branch} was deleted."),
5513                })
5514            }
5515            _ => released.map(|path| {
5516                format!(
5517                    " The worktree at {} was released, but branch {branch} was kept.",
5518                    path.display()
5519                )
5520            }),
5521        }
5522    }
5523
5524    /// `gh pr merge --delete-branch` exits non-zero when it cannot delete the
5525    /// local branch, which happens *after* the merge has already landed.
5526    /// Treating that as a failure reports work as lost when it is not.
5527    pub fn merge_pr(&self, number: i64) -> Result<()> {
5528        let n = number.to_string();
5529        let merged = match self.gh(&merge_pr_args(&n, None, true)) {
5530            Ok(_) => Ok(()),
5531            Err(e) => {
5532                if self.pr_state(number) == "MERGED" {
5533                    let finished = self
5534                        .finish_merged_branch_cleanup(number)
5535                        .unwrap_or_default();
5536                    logdim!(
5537                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5538                        e.last_line()
5539                    );
5540                    Ok(())
5541                } else {
5542                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5543                }
5544            }
5545        };
5546        self.record_write(merged)
5547    }
5548
5549    /// Squash merge only if the pull request still exposes the reviewed head.
5550    pub fn merge_pr_at_head(
5551        &self,
5552        number: i64,
5553        expected_head: &str,
5554        delete_branch: bool,
5555    ) -> Result<()> {
5556        let n = number.to_string();
5557        let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
5558            Ok(_) => Ok(()),
5559            Err(e) => {
5560                if self.pr_state(number) == "MERGED" {
5561                    let finished = self
5562                        .finish_merged_branch_cleanup(number)
5563                        .unwrap_or_default();
5564                    logdim!(
5565                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
5566                        e.last_line()
5567                    );
5568                    Ok(())
5569                } else {
5570                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
5571                }
5572            }
5573        };
5574        self.record_write(merged)
5575    }
5576
5577    // -- follow-ups -------------------------------------------------------
5578
5579    /// The queue of follow-ups recorded locally rather than filed, which
5580    /// `spar followup` works.
5581    pub fn followups_path(&self) -> PathBuf {
5582        self.root.join(STATE_DIR).join("followups.md")
5583    }
5584
5585    /// Where `spar brainstorm` writes a session unless told otherwise.
5586    ///
5587    /// Under the state directory, so it is kept out of the repository's own
5588    /// `git status`; `--out` puts one somewhere a person would commit.
5589    pub fn brainstorms_dir(&self) -> PathBuf {
5590        self.root.join(STATE_DIR).join("brainstorms")
5591    }
5592
5593    /// What `spar followup` already dealt with, kept beside the queue.
5594    ///
5595    /// Two jobs. It is what stops `append_local_followup` re-recording a
5596    /// follow-up whose entry has since left the queue, which would otherwise
5597    /// turn the file into a ring buffer of things already filed. And it keeps
5598    /// the text of an entry a screening pass ruled stale, so a wrong verdict
5599    /// costs a re-read rather than the only copy of a real defect.
5600    pub fn worked_followups_path(&self) -> PathBuf {
5601        self.root.join(STATE_DIR).join("followups.done.md")
5602    }
5603
5604    /// What `spar checkin` has already answered on one pull request or issue.
5605    /// Where what a run spent is kept.
5606    pub fn spend_path(&self) -> PathBuf {
5607        self.root.join(STATE_DIR).join("state").join("spend.json")
5608    }
5609
5610    /// Append this run's calls to the record.
5611    ///
5612    /// Appended rather than replaced, so a resumed pull request adds to the
5613    /// count of what it has cost so far rather than starting it again, and so
5614    /// anybody charting this has a file to chart.
5615    pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
5616        if calls.is_empty() {
5617            return Ok(());
5618        }
5619        let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
5620            .ok()
5621            .and_then(|text| serde_json::from_str(&text).ok())
5622            .unwrap_or_default();
5623        all.extend(calls.iter().cloned());
5624        if let Some(parent) = self.spend_path().parent() {
5625            let _ = std::fs::create_dir_all(parent);
5626        }
5627        write_json_atomic(&self.spend_path(), &all)
5628    }
5629
5630    /// Who wrote the first draft of each issue, by number.
5631    ///
5632    /// Custody alternates within a pull request and the record of it lives in
5633    /// that pull request's state. This is the other question, which nothing
5634    /// answered: over a queue, which model authored what.
5635    pub fn implementors_path(&self) -> PathBuf {
5636        self.root
5637            .join(STATE_DIR)
5638            .join("state")
5639            .join("implementors.json")
5640    }
5641
5642    pub fn read_implementors(&self) -> BTreeMap<i64, String> {
5643        std::fs::read_to_string(self.implementors_path())
5644            .ok()
5645            .and_then(|text| serde_json::from_str(&text).ok())
5646            .unwrap_or_default()
5647    }
5648
5649    /// Best effort: this is a record for a person, not something the loop reads
5650    /// back, so a failure to write it is not a reason to stop.
5651    pub fn remember_implementor(&self, issue: i64, agent: &str) {
5652        let mut known = self.read_implementors();
5653        known.insert(issue, agent.to_string());
5654        if let Some(parent) = self.implementors_path().parent() {
5655            let _ = std::fs::create_dir_all(parent);
5656        }
5657        if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
5658            logdim!("could not record who implemented #{issue}: {e}");
5659        }
5660    }
5661
5662    /// Where the issues triage disagreed about are remembered.
5663    ///
5664    /// One file rather than one per issue: it is read once per run, before any
5665    /// triage call, to decide what not to ask about again.
5666    pub fn contested_path(&self) -> PathBuf {
5667        self.root
5668            .join(STATE_DIR)
5669            .join("state")
5670            .join("contested.json")
5671    }
5672
5673    /// The issues a person has been asked to decide, by number.
5674    pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
5675        std::fs::read_to_string(self.contested_path())
5676            .ok()
5677            .and_then(|text| serde_json::from_str(&text).ok())
5678            .unwrap_or_default()
5679    }
5680
5681    /// Remember an issue triage could not agree on.
5682    ///
5683    /// Best effort: a run that cannot write this asks again next time, which is
5684    /// what it did before this existed.
5685    pub fn remember_contested(&self, items: &[ContestedItem]) {
5686        if items.is_empty() {
5687            return;
5688        }
5689        let mut known = self.read_contested();
5690        for item in items {
5691            known.insert(
5692                item.issue,
5693                Contested {
5694                    title: item.title.clone(),
5695                    positions: item.positions.clone(),
5696                    reasons: item.reasons.clone(),
5697                },
5698            );
5699        }
5700        if let Some(parent) = self.contested_path().parent() {
5701            let _ = std::fs::create_dir_all(parent);
5702        }
5703        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5704            logdim!("could not record the contested issues: {e}");
5705        }
5706    }
5707
5708    /// Ask about them again, because a person has acted on them.
5709    pub fn forget_contested(&self, numbers: &[i64]) {
5710        let mut known = self.read_contested();
5711        let before = known.len();
5712        known.retain(|number, _| !numbers.contains(number));
5713        if known.len() == before {
5714            return;
5715        }
5716        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
5717            logdim!("could not update the contested issues: {e}");
5718        }
5719    }
5720
5721    pub fn checkin_state_path(&self, number: i64) -> PathBuf {
5722        self.root
5723            .join(STATE_DIR)
5724            .join("state")
5725            .join(format!("checkin-{number}.json"))
5726    }
5727
5728    /// Where `spar review` records what it reviewed, and a second look reads it.
5729    pub fn review_record_path(&self, number: i64) -> PathBuf {
5730        self.root
5731            .join(STATE_DIR)
5732            .join("state")
5733            .join(format!("review-{number}.json"))
5734    }
5735
5736    /// None when there is no record, or when it cannot be read. A record spar
5737    /// cannot read is one it reviews afresh over, and the log says so.
5738    pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
5739        let path = self.review_record_path(number);
5740        let text = std::fs::read_to_string(&path).ok()?;
5741        match serde_json::from_str(&text) {
5742            Ok(record) => Some(record),
5743            Err(e) => {
5744                logdim!("could not read {}: {e}", path.display());
5745                None
5746            }
5747        }
5748    }
5749
5750    pub fn write_review_record(
5751        &self,
5752        number: i64,
5753        record: &crate::model::ReviewRecord,
5754    ) -> Result<()> {
5755        write_json_atomic(&self.review_record_path(number), record)
5756    }
5757
5758    /// Append a follow-up to a local note instead of the tracker.
5759    ///
5760    /// Deduplicated on the title, matching the issue path. The body arrives
5761    /// with its provenance already stamped by the caller, so nothing is added
5762    /// here.
5763    ///
5764    /// A write that did not happen is reported as such rather than as a
5765    /// duplicate: the caller settles the point on the strength of this answer,
5766    /// and settling it on a failed write is how a real defect is lost.
5767    ///
5768    /// Both files are checked, because `spar followup` removes an entry from
5769    /// the queue once it has filed it. Checking only the queue would let the
5770    /// next run that rediscovers the same defect append it again, on top of the
5771    /// issue that now exists for it.
5772    pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
5773        let path = self.followups_path();
5774        let heading = format!("## {}", title.trim());
5775        for seen in [&path, &self.worked_followups_path()] {
5776            if let Ok(existing) = std::fs::read_to_string(seen) {
5777                if has_heading(&existing, title) {
5778                    logdim!("follow-up already noted: {title}");
5779                    return Followup::Covered(format!("note: {}", title.trim()));
5780                }
5781            }
5782        }
5783        if let Some(parent) = path.parent() {
5784            let _ = std::fs::create_dir_all(parent);
5785        }
5786        use std::io::Write;
5787        // The caller already stamped the provenance into the body. Adding
5788        // "From #N." here as well printed it twice, in two different wordings.
5789        //
5790        // The marker above the heading is what makes the entry boundary
5791        // unambiguous to the parser, since the body's own sections are written
5792        // at the same heading level as the title.
5793        let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
5794        match std::fs::OpenOptions::new()
5795            .create(true)
5796            .append(true)
5797            .open(&path)
5798        {
5799            Ok(mut file) => match file.write_all(entry.as_bytes()) {
5800                Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
5801                Err(e) => {
5802                    logdim!("could not write {}: {e}", path.display());
5803                    Followup::Failed
5804                }
5805            },
5806            Err(e) => {
5807                logdim!("could not write {}: {e}", path.display());
5808                Followup::Failed
5809            }
5810        }
5811    }
5812
5813    /// Record what `spar followup` did with an entry, and why.
5814    ///
5815    /// Best effort: an archive that could not be written is not a reason to
5816    /// stop, since the entry has already been filed or ruled on.
5817    pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
5818        let path = self.worked_followups_path();
5819        if let Some(parent) = path.parent() {
5820            let _ = std::fs::create_dir_all(parent);
5821        }
5822        use std::io::Write;
5823        let entry = format!(
5824            "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
5825            title.trim(),
5826            body.trim()
5827        );
5828        if let Ok(mut file) = std::fs::OpenOptions::new()
5829            .create(true)
5830            .append(true)
5831            .open(&path)
5832        {
5833            let _ = file.write_all(entry.as_bytes());
5834        }
5835    }
5836
5837    // -- resumable state --------------------------------------------------
5838    //
5839    // Custody cannot be read from GitHub authorship: every agent commits and
5840    // comments as the same git identity, so `author` is always the human who
5841    // ran spar. State is kept on disk by default and can additionally travel in
5842    // a PR comment, which is what lets a run be resumed from another machine.
5843
5844    /// Where a comment spar produced but did not post is kept.
5845    pub fn pending_comment_path(&self, number: i64) -> PathBuf {
5846        self.root
5847            .join(STATE_DIR)
5848            .join("reviews")
5849            .join(format!("pr-{number}.md"))
5850    }
5851
5852    /// Keep a comment spar decided not to post.
5853    ///
5854    /// A dry run that prints and forgets means agreeing with what you read
5855    /// costs a second full review. Saving it makes the whole point of reading
5856    /// it first: look, edit if you like, then post what you already paid for.
5857    pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
5858        let path = self.pending_comment_path(number);
5859        if let Some(parent) = path.parent() {
5860            std::fs::create_dir_all(parent)
5861                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
5862        }
5863        std::fs::write(&path, text)
5864            .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5865        Ok(path)
5866    }
5867
5868    pub fn read_pending_comment(&self, number: i64) -> Option<String> {
5869        std::fs::read_to_string(self.pending_comment_path(number)).ok()
5870    }
5871
5872    pub fn state_path(&self, number: i64) -> PathBuf {
5873        self.root
5874            .join(STATE_DIR)
5875            .join("state")
5876            .join(format!("pr-{number}.json"))
5877    }
5878
5879    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
5880        let path = self.state_path(number);
5881        let text = std::fs::read_to_string(&path).ok()?;
5882        match serde_json::from_str(&text) {
5883            Ok(state) => Some(state),
5884            Err(_) => {
5885                logdim!("could not read {}, starting fresh", path.display());
5886                None
5887            }
5888        }
5889    }
5890
5891    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
5892        if let Some(local) = self.read_local_state(pr.number) {
5893            return Some(local);
5894        }
5895        if self.state_store.writes_pr() {
5896            return self.read_pr_state(pr.number);
5897        }
5898        None
5899    }
5900
5901    pub(crate) fn read_state_for_head(
5902        &self,
5903        pr: &PrView,
5904        actual_head: &str,
5905    ) -> Option<PersistedState> {
5906        let local = self
5907            .state_store
5908            .writes_local()
5909            .then(|| self.read_local_state(pr.number))
5910            .flatten();
5911        let remote = self
5912            .state_store
5913            .writes_pr()
5914            .then(|| self.read_pr_state(pr.number))
5915            .flatten();
5916        let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
5917        if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
5918            self.remember_checkpoint(pr.number, checkpoint);
5919        }
5920        choose_state_for_head(candidates, actual_head)
5921    }
5922
5923    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
5924        self.try_read_pr_state(number).ok().flatten()
5925    }
5926
5927    fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
5928        for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
5929            if let Some(state) = parse_state_comment(&body) {
5930                return Ok(Some(state));
5931            }
5932        }
5933        Ok(None)
5934    }
5935
5936    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5937        let remote_state = if self.state_store.writes_pr() {
5938            self.try_read_pr_state(number)
5939        } else {
5940            Ok(None)
5941        };
5942        self.write_state_after_remote_read(number, state, remote_state)
5943    }
5944
5945    fn write_state_after_remote_read(
5946        &self,
5947        number: i64,
5948        state: &PersistedState,
5949        remote_state: Result<Option<PersistedState>>,
5950    ) -> Result<()> {
5951        let remote_checkpoint = if self.state_store.writes_pr() {
5952            self.record_failed_write(remote_state)?
5953                .map(|saved| saved.checkpoint)
5954                .unwrap_or_default()
5955        } else {
5956            0
5957        };
5958        let local_checkpoint = self
5959            .state_store
5960            .writes_local()
5961            .then(|| self.read_local_state(number))
5962            .flatten()
5963            .map(|saved| saved.checkpoint)
5964            .unwrap_or_default();
5965        let mut stamped = state.clone();
5966        stamped.checkpoint = state
5967            .checkpoint
5968            .max(local_checkpoint)
5969            .max(remote_checkpoint)
5970            .max(self.remembered_checkpoint(number))
5971            .saturating_add(1);
5972        self.remember_checkpoint(number, stamped.checkpoint);
5973        if self.state_store.writes_local() {
5974            write_json_atomic(&self.state_path(number), &stamped)?;
5975        }
5976        if self.state_store.writes_pr() {
5977            self.write_pr_state(number, &stamped)?;
5978        }
5979        Ok(())
5980    }
5981
5982    fn remembered_checkpoint(&self, number: i64) -> u64 {
5983        self.checkpoints
5984            .lock()
5985            .unwrap_or_else(std::sync::PoisonError::into_inner)
5986            .get(&number)
5987            .copied()
5988            .unwrap_or_default()
5989    }
5990
5991    fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
5992        let mut checkpoints = self
5993            .checkpoints
5994            .lock()
5995            .unwrap_or_else(std::sync::PoisonError::into_inner);
5996        let saved = checkpoints.entry(number).or_default();
5997        *saved = (*saved).max(checkpoint);
5998    }
5999
6000    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6001        // Not run through clean(): this is structured data, and scrubbing would
6002        // corrupt refutation text stored in the ledger. It sits inside an
6003        // unclosed HTML comment so GitHub renders it as nothing.
6004        let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
6005        let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
6006        let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
6007        if let Some(id) = comment_id {
6008            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6009            let field = format!("body={body}");
6010            let written = self
6011                .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
6012                .map(|_| ());
6013            return self.record_write(written);
6014        }
6015        let written = self
6016            .gh(&["pr", "comment", &number.to_string(), "--body", &body])
6017            .map(|_| ());
6018        self.record_write(written)
6019    }
6020
6021    /// Top level comments. Works for issues and pull requests alike, because
6022    /// GitHub serves both from the issues endpoint.
6023    ///
6024    /// Nothing when they cannot be read, which suits a reader that is going to
6025    /// go on regardless. A caller deciding whether it has already written here
6026    /// wants `try_issue_comments`, since for that one no comments and no answer
6027    /// are opposite answers.
6028    pub fn issue_comments(&self, number: i64) -> Vec<Value> {
6029        self.try_issue_comments(number).unwrap_or_default()
6030    }
6031
6032    pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
6033        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
6034        try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
6035    }
6036
6037    /// spar's own state comments on a pull request, newest last.
6038    ///
6039    /// Three tests, because the answer decides what gets patched over and what
6040    /// gets deleted. A comment matching the marker loosely, which is what
6041    /// somebody asking "why is there a `<!-- spar:state` block on this PR?"
6042    /// writes, is named in the log and left exactly where it is.
6043    fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
6044        let viewer = self.viewer_login()?.to_string();
6045        let mut mine = Vec::new();
6046        let mut theirs = Vec::new();
6047        for comment in self.try_issue_comments(number)? {
6048            let Some(body) = comment.get("body").and_then(Value::as_str) else {
6049                continue;
6050            };
6051            if !body.contains("spar:state") {
6052                continue;
6053            }
6054            let Some(id) = comment.get("id").and_then(Value::as_i64) else {
6055                continue;
6056            };
6057            let author = comment
6058                .get("user")
6059                .and_then(|u| u.get("login"))
6060                .and_then(Value::as_str)
6061                .unwrap_or_default();
6062            if is_state_comment(body, author, &viewer) {
6063                mine.push((id, body.to_string()));
6064            } else {
6065                theirs.push(format!("@{author}"));
6066            }
6067        }
6068        if !theirs.is_empty() {
6069            logdim!(
6070                "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
6071                 own state block is patched or deleted.",
6072                theirs.len(),
6073                theirs.join(", ")
6074            );
6075        }
6076        Ok(mine)
6077    }
6078
6079    fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
6080        Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
6081    }
6082
6083    /// Drop state once the PR is finished and there is nothing to resume.
6084    pub fn clear_state(&self, number: i64) {
6085        let path = self.state_path(number);
6086        let _ = std::fs::remove_file(&path);
6087        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
6088    }
6089
6090    // -- housekeeping -----------------------------------------------------
6091
6092    /// Remove state files whose PR is merged or closed: the custody loop's
6093    /// state, the review record, and the check-in watermark alike. Grouped by
6094    /// number so each finished pull request costs one `gh` read however many
6095    /// files it left.
6096    pub fn prune_state(&self) -> Vec<String> {
6097        let base = self.root.join(STATE_DIR).join("state");
6098        let Ok(entries) = std::fs::read_dir(&base) else {
6099            return Vec::new();
6100        };
6101        let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
6102        for name in entries
6103            .flatten()
6104            .filter_map(|e| e.file_name().to_str().map(str::to_string))
6105        {
6106            if let Some(number) = state_file_number(&name) {
6107                by_number.entry(number).or_default().push(name);
6108            }
6109        }
6110
6111        let mut removed = Vec::new();
6112        for (number, mut names) in by_number {
6113            if !is_finished(&self.pr_state(number)) {
6114                continue;
6115            }
6116            names.sort();
6117            for name in names {
6118                let _ = std::fs::remove_file(base.join(&name));
6119                removed.push(format!("state {name}"));
6120            }
6121        }
6122        removed
6123    }
6124
6125    /// Delete state comments from PRs that are finished.
6126    ///
6127    /// Open PRs are left alone: their state may still be live.
6128    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
6129        #[derive(Deserialize)]
6130        struct Row {
6131            number: i64,
6132        }
6133        let numbers = match numbers {
6134            Some(numbers) => numbers,
6135            None => {
6136                let listed: Result<Vec<i64>> = (|| {
6137                    let text = self.gh(&[
6138                        "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
6139                    ])?;
6140                    let rows = serde_json::from_str::<Vec<Row>>(text.trim())
6141                        .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
6142                    Ok(rows.into_iter().map(|row| row.number).collect())
6143                })();
6144                match self.record_failed_write(listed) {
6145                    Ok(numbers) => numbers,
6146                    Err(e) => {
6147                        logdim!("could not inspect pull requests for state cleanup: {e}");
6148                        return Vec::new();
6149                    }
6150                }
6151            }
6152        };
6153
6154        let mut removed = Vec::new();
6155        for number in numbers {
6156            let state = match self.record_failed_write(self.try_pr_state(number)) {
6157                Ok(state) => state,
6158                Err(e) => {
6159                    logdim!("could not inspect PR #{number} for state cleanup: {e}");
6160                    continue;
6161                }
6162            };
6163            if !is_finished(&state) {
6164                continue;
6165            }
6166            let comments = match self.record_failed_write(self.try_state_comments(number)) {
6167                Ok(comments) => comments,
6168                Err(e) => {
6169                    logdim!("could not inspect state comments on PR #{number}: {e}");
6170                    continue;
6171                }
6172            };
6173            for (id, _) in comments {
6174                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6175                let deleted = self
6176                    .gh(&["api", "-X", "DELETE", &path, "--silent"])
6177                    .map(|_| ());
6178                match self.record_write(deleted) {
6179                    Ok(()) => removed.push(format!("state comment on PR #{number}")),
6180                    Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
6181                }
6182            }
6183        }
6184        removed
6185    }
6186
6187    /// Drop worktrees whose PR is finished, then the branches they left behind.
6188    ///
6189    /// With auto_merge off, which is the default, a run ends at "approved", so
6190    /// nothing would ever clean these up on its own and they accumulate one per
6191    /// run. A stranded worktree also holds its branch checked out, which makes
6192    /// a later `gh pr merge --delete-branch` fail to clean up.
6193    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
6194        let base = self.root.join(WORKTREE_DIR);
6195        let mut removed = Vec::new();
6196        let known = self.known_branches();
6197
6198        if let Ok(entries) = std::fs::read_dir(&base) {
6199            let mut names: Vec<String> = entries
6200                .flatten()
6201                .filter(|e| e.path().is_dir())
6202                .filter_map(|e| e.file_name().to_str().map(str::to_string))
6203                .collect();
6204            names.sort();
6205
6206            for name in names {
6207                // A review worktree is detached and owns no branch, so it is
6208                // tied to the pull request only by its directory name.
6209                if let Some(rest) = name.strip_prefix("review-") {
6210                    let number: i64 = rest.parse().unwrap_or(-1);
6211                    if !(force_all || is_finished(&self.pr_state(number))) {
6212                        continue;
6213                    }
6214                    let path = base.join(&name);
6215                    if force_all {
6216                        let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
6217                            if !belongs {
6218                                return Ok(false);
6219                            }
6220                            let local_ref = review_ref(number);
6221                            if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
6222                                return Ok(false);
6223                            }
6224                            let head = self.head_oid_checked(&path)?;
6225                            let recorded = self
6226                                .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
6227                                .trim()
6228                                .to_string();
6229                            Ok(head == recorded)
6230                        });
6231                        match owned {
6232                            Ok(true) => {}
6233                            Ok(false) => {
6234                                logdim!(
6235                                    "kept {} because no matching SPAR review reference proves \
6236                                     ownership",
6237                                    path.display()
6238                                );
6239                                continue;
6240                            }
6241                            Err(e) => {
6242                                logdim!(
6243                                    "kept {} because review ownership could not be verified: {}",
6244                                    path.display(),
6245                                    e.last_line()
6246                                );
6247                                continue;
6248                            }
6249                        }
6250                    } else {
6251                        if let Err(e) = self.refuse_review_worktree_changes(number) {
6252                            logdim!(
6253                                "kept {} because its review state could not be verified as \
6254                                 disposable: {}",
6255                                path.display(),
6256                                e.last_line()
6257                            );
6258                            continue;
6259                        }
6260                    }
6261                    if force_all {
6262                        if self.remove_worktree_at_force(&path) {
6263                            self.git_try(&["update-ref", "-d", &review_ref(number)]);
6264                        }
6265                    } else {
6266                        self.release_review_worktree(number);
6267                    }
6268                    if !path.exists() {
6269                        removed.push(name);
6270                    }
6271                    continue;
6272                }
6273                let branch = format!("{}{name}", self.branch_prefix);
6274                if !(force_all || self.worktree_is_done(&branch)) {
6275                    continue;
6276                }
6277                if !known.contains_key(&branch) {
6278                    logdim!("kept {branch} because it has no branch record");
6279                    continue;
6280                }
6281                let path = base.join(&name);
6282                if !force_all {
6283                    match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6284                        Ok(true) => {
6285                            logdim!(
6286                                "kept {} because it contains uncommitted changes or ignored files",
6287                                path.display()
6288                            );
6289                            continue;
6290                        }
6291                        Err(e) => {
6292                            logdim!(
6293                                "kept {} because its Git state could not be checked: {}",
6294                                path.display(),
6295                                e.last_line()
6296                            );
6297                            continue;
6298                        }
6299                        Ok(false) => {}
6300                    }
6301                    match self.branch_deletion_is_safe(&branch) {
6302                        Ok(true) => {}
6303                        Ok(false) => {
6304                            logdim!(
6305                                "kept {branch} because no surviving ref preserves its tip or \
6306                                 reflog-only commits"
6307                            );
6308                            continue;
6309                        }
6310                        Err(e) => {
6311                            logdim!(
6312                                "kept {branch} because preservation could not be verified: {}",
6313                                e.last_line()
6314                            );
6315                            continue;
6316                        }
6317                    }
6318                }
6319                let removed_worktree = if force_all {
6320                    self.remove_worktree_at_force(&path)
6321                } else {
6322                    match self.remove_worktree_at(&path) {
6323                        Ok(removed) => removed,
6324                        Err(error) => {
6325                            logdim!(
6326                                "kept {branch} and {} because removal did not reach a confirmed \
6327                                 quiet point: {}",
6328                                path.display(),
6329                                error.last_line()
6330                            );
6331                            false
6332                        }
6333                    }
6334                };
6335                if !removed_worktree {
6336                    continue;
6337                }
6338                if force_all {
6339                    self.git_try(&["branch", "-D", &branch]);
6340                    self.forget_branch(&branch);
6341                } else {
6342                    match self.delete_branch_if_safe(&branch) {
6343                        Ok(true) => self.forget_branch(&branch),
6344                        Ok(false) => logdim!(
6345                            "kept {branch} because its tip or reflog changed before deletion"
6346                        ),
6347                        Err(error) => logdim!(
6348                            "kept {branch} because deletion safety could not be rechecked: {}",
6349                            error.last_line()
6350                        ),
6351                    }
6352                }
6353                removed.push(name);
6354            }
6355        }
6356        removed.extend(self.prune_branches(force_all));
6357        removed
6358    }
6359
6360    /// Delete leftover branches spar created whose worktree is already gone.
6361    ///
6362    /// Deletion is driven by the ledger of branches spar actually created, not
6363    /// by a name pattern. Names default to `issue-N`, which is exactly what a
6364    /// person would call a branch themselves, so a name alone can never
6365    /// establish ownership. This is the data loss guard.
6366    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
6367        let known = self.known_branches();
6368        let branches: Vec<String> = known.keys().cloned().collect();
6369        if branches.is_empty() {
6370            return Vec::new();
6371        }
6372
6373        let checked_out: Vec<String> = self
6374            .git_try(&["worktree", "list", "--porcelain"])
6375            .lines()
6376            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
6377            .collect();
6378
6379        // %(refname:short) is ambiguous when a tag shares the branch name (it
6380        // yields "heads/..."), so take the full ref and strip it here.
6381        let existing: Vec<String> = self
6382            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
6383            .lines()
6384            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
6385            .collect();
6386
6387        let mut removed = Vec::new();
6388        for branch in branches {
6389            if !existing.contains(&branch) {
6390                self.forget_branch(&branch); // already gone, drop the record
6391                continue;
6392            }
6393            if checked_out.contains(&branch) {
6394                continue;
6395            }
6396            if !(force_all || self.worktree_is_done(&branch)) {
6397                continue;
6398            }
6399            if !force_all {
6400                let Some(_record) = known.get(&branch) else {
6401                    continue;
6402                };
6403                match self.branch_deletion_is_safe(&branch) {
6404                    Ok(true) => {}
6405                    Ok(false) => {
6406                        logdim!(
6407                            "kept {branch} because no surviving ref preserves its tip or \
6408                             reflog-only commits"
6409                        );
6410                        continue;
6411                    }
6412                    Err(e) => {
6413                        logdim!(
6414                            "kept {branch} because preservation could not be verified: {}",
6415                            e.last_line()
6416                        );
6417                        continue;
6418                    }
6419                }
6420            }
6421            let deleted = if force_all {
6422                self.git(&["branch", "-D", &branch]).map(|_| true)
6423            } else {
6424                self.delete_branch_if_safe(&branch)
6425            };
6426            match deleted {
6427                Ok(true) => {
6428                    self.forget_branch(&branch);
6429                    removed.push(format!("branch {branch}"));
6430                }
6431                Ok(false) => {
6432                    logdim!("kept {branch} because its tip or reflog changed before deletion");
6433                }
6434                Err(e) => {
6435                    // A branch that silently survives pruning looks like a spar
6436                    // bug, so the name and git's own reason have to be said.
6437                    logdim!("could not delete {branch}: {}", e.last_line());
6438                }
6439            }
6440        }
6441        removed
6442    }
6443
6444    /// True when the PR behind this branch is merged or closed.
6445    fn worktree_is_done(&self, branch: &str) -> bool {
6446        #[derive(Deserialize)]
6447        struct Row {
6448            state: String,
6449        }
6450        let entry = branch
6451            .strip_prefix(self.branch_prefix.as_str())
6452            .unwrap_or(branch);
6453        if let Some(rest) = entry.strip_prefix("pr-") {
6454            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
6455        }
6456        // A split part is the same shape as an issue branch: one branch, whose
6457        // pull requests say whether it is finished. Without it here, a part
6458        // branch is one nothing but `clean --all` would ever remove.
6459        if entry.starts_with("issue-") || entry.starts_with("split-") {
6460            let text = self.gh_try(&[
6461                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
6462            ]);
6463            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
6464            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
6465        }
6466        false
6467    }
6468}
6469
6470// ---------------------------------------------------------------------------
6471// Free helpers
6472// ---------------------------------------------------------------------------
6473
6474/// Take a read-only probe again before treating its failure as evidence.
6475///
6476/// Every probe is a Git invocation under a thirty second cap plus a walk over a
6477/// tree an agent may still be building in. A probe that fails proves nothing
6478/// about the worktree, and there is a real difference between "the tree moved"
6479/// and "the question could not be asked just then", which the caller turns into
6480/// the same run-ending error.
6481pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
6482    match probe() {
6483        Ok(value) => Ok(value),
6484        Err(first) => {
6485            logdim!("retrying a worktree probe after: {}", first.last_line());
6486            probe()
6487        }
6488    }
6489}
6490
6491/// Read attribute files without asking Git to inspect working-tree content.
6492///
6493/// A newly written attribute can select a clean or smudge filter. It must be
6494/// detected before a post-call status, diff, or add command has a chance to run
6495/// that filter in the parent process.
6496pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
6497    let root = std::fs::canonicalize(cwd)
6498        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6499    let mut files = BTreeMap::new();
6500    let mut visited = BTreeSet::new();
6501    collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
6502    Ok(AttributeState { files })
6503}
6504
6505fn collect_attribute_files(
6506    root: &Path,
6507    repository: &Path,
6508    prefix: &Path,
6509    visited: &mut BTreeSet<PathBuf>,
6510    files: &mut BTreeMap<PathBuf, [u8; 32]>,
6511) -> Result<()> {
6512    let canonical = std::fs::canonicalize(repository)
6513        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6514    if !visited.insert(canonical) {
6515        bail!("submodule recursion revisited {}", repository.display());
6516    }
6517    let entries = index_entries(repository)?;
6518    let mut paths: BTreeSet<PathBuf> = entries
6519        .iter()
6520        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6521        .map(|entry| entry.path.clone())
6522        .collect();
6523    // Ignored on purpose. A `.gitattributes` under build output can only select
6524    // filters for paths that are themselves ignored, and no ignored path is
6525    // ever staged or compared as tracked content, so it cannot reach a managed
6526    // commit. Capturing it meant `cargo package` or `npm ci` writing one into
6527    // its own output ended a read-only call outright.
6528    paths.extend(untracked_attribute_paths(repository)?);
6529    for path in paths {
6530        let from_root = prefix.join(&path);
6531        let state = attribute_file_fingerprint(&root.join(&from_root))?;
6532        files.insert(from_root, state);
6533    }
6534    for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
6535        let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
6536            continue;
6537        };
6538        collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
6539    }
6540    Ok(())
6541}
6542
6543/// Leave a visible, untracked reason ordinary cleanup can detect on a later
6544/// run even when Git status normalizes the original working-file change away.
6545pub(crate) fn uncertain_worktree_change(
6546    cwd: &Path,
6547    message: impl Into<String>,
6548) -> crate::error::SparError {
6549    let message = message.into();
6550    let marker = write_recovery_marker(cwd, &message);
6551    let note = match marker {
6552        Ok(path) => format!(" Recovery marker: {}.", path.display()),
6553        Err(e) => format!(
6554            " A recovery marker could not be written: {}.",
6555            e.last_line()
6556        ),
6557    };
6558    crate::error::SparError::uncertain_write(format!("{message}{note}"))
6559}
6560
6561fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
6562    use std::sync::atomic::{AtomicU32, Ordering};
6563    static NEXT: AtomicU32 = AtomicU32::new(0);
6564    for _ in 0..1000 {
6565        let serial = NEXT.fetch_add(1, Ordering::Relaxed);
6566        let path = cwd.join(format!(
6567            "{RECOVERY_MARKER_PREFIX}{}-{serial}",
6568            std::process::id()
6569        ));
6570        let mut options = OpenOptions::new();
6571        options.write(true).create_new(true);
6572        #[cfg(unix)]
6573        {
6574            use std::os::unix::fs::OpenOptionsExt;
6575            options.mode(0o600);
6576        }
6577        match options.open(&path) {
6578            Ok(mut file) => {
6579                file.write_all(detail.as_bytes())
6580                    .and_then(|_| file.write_all(b"\n"))
6581                    .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6582                return Ok(path);
6583            }
6584            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
6585            Err(e) => {
6586                return Err(spar_err!(
6587                    "could not create a recovery marker in {}: {e}",
6588                    cwd.display()
6589                ))
6590            }
6591        }
6592    }
6593    bail!(
6594        "could not choose a free recovery marker name in {}",
6595        cwd.display()
6596    )
6597}
6598
6599/// Build a Git command that cannot launch automatic repository maintenance.
6600///
6601/// A fetch may otherwise prune missing linked worktree registrations. SPAR
6602/// must only remove registrations it has proven it owns.
6603fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
6604    let mut argv = vec![
6605        "git".to_string(),
6606        "-c".to_string(),
6607        "maintenance.auto=false".to_string(),
6608        "-c".to_string(),
6609        "gc.auto=0".to_string(),
6610    ];
6611    argv.extend(args.iter().map(|arg| (*arg).to_string()));
6612    argv
6613}
6614
6615fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
6616    let mut argv = git_without_maintenance_argv(&[]);
6617    argv.extend([
6618        "-c".to_string(),
6619        "core.fsmonitor=".to_string(),
6620        "-c".to_string(),
6621        "commit.gpgsign=false".to_string(),
6622        "-c".to_string(),
6623        "core.hooksPath=/dev/null".to_string(),
6624    ]);
6625    argv.extend(args.iter().map(|arg| (*arg).to_string()));
6626    argv
6627}
6628
6629/// Snapshot every untracked file, including ignored files, without changing
6630/// path bytes.
6631///
6632/// Without an exclude option, Git lists both ordinary and ignored untracked
6633/// entries. Metadata fingerprints make overwriting an existing path observable
6634/// without hashing a potentially multi-gigabyte build tree on every call.
6635pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
6636    let root = std::fs::canonicalize(cwd)
6637        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6638    let mut files = BTreeMap::new();
6639    let mut ignored = BTreeSet::new();
6640    let mut visited = BTreeSet::new();
6641    collect_untracked_files(
6642        &root,
6643        &root,
6644        Path::new(""),
6645        &mut visited,
6646        &mut files,
6647        &mut ignored,
6648    )?;
6649    Ok(IgnoredState { files, ignored })
6650}
6651
6652fn collect_untracked_files(
6653    root: &Path,
6654    repository: &Path,
6655    prefix: &Path,
6656    visited: &mut BTreeSet<PathBuf>,
6657    files: &mut BTreeMap<PathBuf, UntrackedFile>,
6658    ignored: &mut BTreeSet<PathBuf>,
6659) -> Result<()> {
6660    let canonical = std::fs::canonicalize(repository)
6661        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6662    if !visited.insert(canonical.clone()) {
6663        bail!("submodule recursion revisited {}", canonical.display());
6664    }
6665    // Classify ordinary paths in the listing itself. Listing every path here
6666    // and marking ignored paths in a second query misclassifies build output
6667    // deleted between queries as ordinary untracked work.
6668    let listed = run_git_bytes(
6669        repository,
6670        &["ls-files", "--others", "--exclude-standard", "-z"],
6671    )?;
6672    if !listed.is_empty() && !listed.ends_with(&[0]) {
6673        bail!(
6674            "git returned an unterminated untracked-file list for {}",
6675            repository.display()
6676        );
6677    }
6678
6679    for raw in listed
6680        .split(|byte| *byte == 0)
6681        .filter(|raw| !raw.is_empty())
6682    {
6683        let (relative, nested) = untracked_record(raw, "untracked")?;
6684        let from_root = prefix.join(&relative);
6685        if is_spar_own_path(&from_root) {
6686            continue;
6687        }
6688        let absolute = root.join(&from_root);
6689        let fingerprint = if nested {
6690            nested_repository_fingerprint(&absolute)?
6691        } else {
6692            ignored_file_fingerprint(&absolute)?
6693        };
6694        if files.insert(from_root.clone(), fingerprint).is_some() {
6695            bail!(
6696                "git returned the untracked path more than once: {:?}",
6697                from_root
6698            );
6699        }
6700    }
6701
6702    let ignored_listed = run_git_bytes(
6703        repository,
6704        &[
6705            "ls-files",
6706            "--others",
6707            "--ignored",
6708            "--exclude-standard",
6709            "-z",
6710        ],
6711    )?;
6712    if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
6713        bail!(
6714            "git returned an unterminated ignored-file list for {}",
6715            repository.display()
6716        );
6717    }
6718    // Two `ls-files` runs cannot be made to agree about a tree that is being
6719    // built in. A path named only by the second one was created between them,
6720    // which is a fact about the moment, not a corruption: record it and let the
6721    // comparison decide, rather than ending a run that had nothing wrong with
6722    // it.
6723    for raw in ignored_listed
6724        .split(|byte| *byte == 0)
6725        .filter(|raw| !raw.is_empty())
6726    {
6727        let (relative, nested) = untracked_record(raw, "ignored")?;
6728        let from_root = prefix.join(relative);
6729        if is_spar_own_path(&from_root) {
6730            continue;
6731        }
6732        if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
6733            let absolute = root.join(&from_root);
6734            let fingerprint = if nested {
6735                nested_repository_fingerprint(&absolute)?
6736            } else {
6737                ignored_file_fingerprint(&absolute)?
6738            };
6739            slot.insert(fingerprint);
6740        }
6741        if !ignored.insert(from_root.clone()) {
6742            bail!(
6743                "git returned the ignored path more than once: {:?}",
6744                from_root
6745            );
6746        }
6747    }
6748
6749    for link in gitlinks(repository)? {
6750        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6751            continue;
6752        };
6753        collect_untracked_files(
6754            root,
6755            &submodule,
6756            &prefix.join(&link.path),
6757            visited,
6758            files,
6759            ignored,
6760        )?;
6761    }
6762    Ok(())
6763}
6764
6765fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
6766    let argv = git_without_automation_argv(args);
6767    proc::run_bytes(
6768        &argv,
6769        &ExecOpts::new()
6770            .cwd(cwd)
6771            .timeout_secs(30)
6772            .stop_descendants(true),
6773    )
6774}
6775
6776fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
6777    let argv = git_without_automation_argv(args);
6778    proc::run(
6779        &argv,
6780        &ExecOpts::new()
6781            .cwd(cwd)
6782            .timeout_secs(30)
6783            .stop_descendants(true),
6784    )
6785}
6786
6787fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
6788    let path = path.to_str().ok_or_else(|| {
6789        spar_err!(
6790            "cannot verify filtered content for a non-UTF-8 path in {}",
6791            cwd.display()
6792        )
6793    })?;
6794    let path_arg = format!("--path={path}");
6795    let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
6796    Ok(Sha256::digest(bytes).into())
6797}
6798
6799fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
6800    let relative = path_from_git_bytes(raw)?;
6801    if relative.is_absolute()
6802        || relative.components().any(|component| {
6803            matches!(
6804                component,
6805                std::path::Component::ParentDir
6806                    | std::path::Component::RootDir
6807                    | std::path::Component::Prefix(_)
6808            )
6809        })
6810    {
6811        bail!("git returned an unsafe {kind} path: {:?}", relative);
6812    }
6813    Ok(relative)
6814}
6815
6816/// Split one `ls-files --others` record into its path and whether Git reported
6817/// a nested repository rather than a single file.
6818///
6819/// Git never lists the contents of a repository inside the working tree, so a
6820/// checkout parked there, such as another of SPAR's own worktrees, arrives as
6821/// one record for the directory itself ending in a separator. Git writes that
6822/// separator on every platform. Trimming it keeps the recorded path equal to
6823/// the same path seen any other way.
6824fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
6825    let nested = raw.last() == Some(&b'/');
6826    let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
6827    if trimmed.is_empty() {
6828        bail!("git returned an empty {kind} path");
6829    }
6830    Ok((safe_git_path(trimmed, kind)?, nested))
6831}
6832
6833fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
6834    let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
6835    if !listed.is_empty() && !listed.ends_with(&[0]) {
6836        bail!(
6837            "git returned an unterminated index listing for {}",
6838            cwd.display()
6839        );
6840    }
6841    let mut entries = Vec::new();
6842    for record in listed
6843        .split(|byte| *byte == 0)
6844        .filter(|record| !record.is_empty())
6845    {
6846        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6847            bail!(
6848                "git returned a malformed index record for {}",
6849                cwd.display()
6850            );
6851        };
6852        let header = &record[..tab];
6853        let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
6854        if fields.len() != 3 {
6855            bail!(
6856                "git returned a malformed index header for {}",
6857                cwd.display()
6858            );
6859        }
6860        if fields[2] != b"0" {
6861            continue;
6862        }
6863        let mode = std::str::from_utf8(fields[0])
6864            .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
6865            .to_string();
6866        let oid = std::str::from_utf8(fields[1])
6867            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6868            .to_string();
6869        entries.push(IndexEntry {
6870            path: safe_git_path(&record[tab + 1..], "index")?,
6871            mode,
6872            oid,
6873        });
6874    }
6875    Ok(entries)
6876}
6877
6878/// Share Git's complete ignore policy between call checkpoints and handoffs.
6879fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
6880    let listed = run_git_bytes(
6881        cwd,
6882        &[
6883            "ls-files",
6884            "--others",
6885            "--exclude-standard",
6886            "-z",
6887            "--",
6888            ".gitattributes",
6889            ":(glob)**/.gitattributes",
6890        ],
6891    )?;
6892    if !listed.is_empty() && !listed.ends_with(&[0]) {
6893        bail!(
6894            "git returned an unterminated attribute-file listing for {}",
6895            cwd.display()
6896        );
6897    }
6898    listed
6899        .split(|byte| *byte == 0)
6900        .filter(|record| !record.is_empty())
6901        .map(|raw| safe_git_path(raw, "attribute"))
6902        .collect()
6903}
6904
6905fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
6906    if !untracked_attribute_paths(cwd)?.is_empty() {
6907        return Ok(true);
6908    }
6909
6910    let index = index_entries(cwd)?
6911        .into_iter()
6912        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6913        .map(|entry| (entry.path, (entry.mode, entry.oid)))
6914        .collect::<BTreeMap<_, _>>();
6915    let head = tree_entries(cwd, "HEAD")?
6916        .into_iter()
6917        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6918        .map(|entry| (entry.path, (entry.mode, entry.oid)))
6919        .collect::<BTreeMap<_, _>>();
6920    if index != head {
6921        return Ok(true);
6922    }
6923
6924    let effective = check_attributes(cwd, index.keys().cloned())?;
6925    let config = CheckoutConfig::read(cwd)?;
6926    for (path, (_mode, oid)) in index {
6927        let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
6928            return Ok(true);
6929        };
6930        let attributes = effective
6931            .get(&path)
6932            .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
6933        if allows_expected_crlf(&config, attributes)? {
6934            if worktree.mode == "120000" {
6935                return Ok(true);
6936            }
6937            let (normalized, every_lf_was_crlf) =
6938                normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
6939            if !every_lf_was_crlf || normalized != oid {
6940                return Ok(true);
6941            }
6942        } else if worktree.raw_oid != oid {
6943            return Ok(true);
6944        }
6945    }
6946    Ok(false)
6947}
6948
6949fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
6950    Ok(index_entries(cwd)?
6951        .into_iter()
6952        .filter(|entry| entry.mode == "160000")
6953        .map(|entry| Gitlink {
6954            path: entry.path,
6955            oid: entry.oid,
6956        })
6957        .collect())
6958}
6959
6960fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
6961    let mut tracked = BTreeMap::new();
6962    for entry in index_entries(cwd)? {
6963        if entry.mode == "160000" {
6964            continue;
6965        }
6966        let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
6967        tracked.insert(
6968            entry.path,
6969            TrackedEntry {
6970                index_mode: entry.mode,
6971                index_oid: entry.oid,
6972                worktree,
6973            },
6974        );
6975    }
6976    Ok(tracked)
6977}
6978
6979fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
6980    let metadata = match std::fs::symlink_metadata(path) {
6981        Ok(metadata) => metadata,
6982        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6983        Err(e) => {
6984            return Err(spar_err!(
6985                "could not inspect tracked file {}: {e}",
6986                path.display()
6987            ))
6988        }
6989    };
6990    let mut fingerprint = Sha256::new();
6991    if metadata.file_type().is_symlink() {
6992        let target = std::fs::read_link(path)
6993            .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
6994        let bytes = os_str_bytes(target.as_os_str())?;
6995        fingerprint.update(b"symlink\0");
6996        fingerprint.update(&bytes);
6997        let content = Sha256::digest(&bytes).into();
6998        return Ok(Some(WorktreeFile {
6999            mode: "120000".to_string(),
7000            #[cfg(unix)]
7001            permissions: 0,
7002            raw_oid: git_blob_oid(oid_len, &bytes)?,
7003            fingerprint: fingerprint.finalize().into(),
7004            content,
7005        }));
7006    }
7007    if !metadata.is_file() {
7008        bail!("tracked path {} is not a file or symlink", path.display());
7009    }
7010
7011    let mut options = OpenOptions::new();
7012    options.read(true);
7013    #[cfg(unix)]
7014    {
7015        use std::os::unix::fs::OpenOptionsExt;
7016        options.custom_flags(libc::O_NOFOLLOW);
7017    }
7018    let mut file = options
7019        .open(path)
7020        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7021    let before = file
7022        .metadata()
7023        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7024    let mode = tracked_file_mode(&before);
7025    #[cfg(unix)]
7026    let permissions = {
7027        use std::os::unix::fs::MetadataExt;
7028        before.mode() & 0o7777
7029    };
7030    fingerprint.update(b"file\0");
7031    fingerprint.update(mode.as_bytes());
7032    #[cfg(unix)]
7033    fingerprint.update(permissions.to_le_bytes());
7034    fingerprint.update(before.len().to_le_bytes());
7035    let mut content = Sha256::new();
7036    let header = format!("blob {}\0", before.len());
7037    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7038    let mut buf = [0u8; 64 * 1024];
7039    loop {
7040        let read = file
7041            .read(&mut buf)
7042            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7043        if read == 0 {
7044            break;
7045        }
7046        fingerprint.update(&buf[..read]);
7047        content.update(&buf[..read]);
7048        object.update(&buf[..read]);
7049    }
7050    let after = file
7051        .metadata()
7052        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7053    if before.len() != after.len()
7054        || before.modified().ok() != after.modified().ok()
7055        || before.permissions() != after.permissions()
7056    {
7057        bail!(
7058            "tracked file {} changed while it was being inspected",
7059            path.display()
7060        );
7061    }
7062    let current = std::fs::symlink_metadata(path)
7063        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7064    if !same_file(&after, &current) {
7065        bail!(
7066            "tracked file {} was replaced while it was being inspected",
7067            path.display()
7068        );
7069    }
7070    Ok(Some(WorktreeFile {
7071        mode,
7072        #[cfg(unix)]
7073        permissions,
7074        raw_oid: object.finish(),
7075        fingerprint: fingerprint.finalize().into(),
7076        content: content.finalize().into(),
7077    }))
7078}
7079
7080fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
7081    let metadata = std::fs::symlink_metadata(path)
7082        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7083    let mut digest = Sha256::new();
7084    if metadata.file_type().is_symlink() {
7085        digest.update(b"symlink\0");
7086        let target = std::fs::read_link(path)
7087            .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
7088        digest.update(os_str_bytes(target.as_os_str())?);
7089        return Ok(digest.finalize().into());
7090    }
7091    if !metadata.is_file() {
7092        bail!("attribute path {} is not a file or symlink", path.display());
7093    }
7094    let mut options = OpenOptions::new();
7095    options.read(true);
7096    #[cfg(unix)]
7097    {
7098        use std::os::unix::fs::OpenOptionsExt;
7099        options.custom_flags(libc::O_NOFOLLOW);
7100    }
7101    let mut file = options
7102        .open(path)
7103        .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7104    let before = file
7105        .metadata()
7106        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7107    digest.update(b"file\0");
7108    let mut buf = [0u8; 64 * 1024];
7109    loop {
7110        let read = file
7111            .read(&mut buf)
7112            .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7113        if read == 0 {
7114            break;
7115        }
7116        digest.update(&buf[..read]);
7117    }
7118    let after = file
7119        .metadata()
7120        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7121    let current = std::fs::symlink_metadata(path)
7122        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7123    if before.len() != after.len()
7124        || before.modified().ok() != after.modified().ok()
7125        || !same_file(&after, &current)
7126    {
7127        bail!(
7128            "attribute file {} changed while it was being inspected",
7129            path.display()
7130        );
7131    }
7132    Ok(digest.finalize().into())
7133}
7134
7135enum ObjectHasher {
7136    Sha1(Sha1),
7137    Sha256(Sha256),
7138}
7139
7140impl ObjectHasher {
7141    fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
7142        let mut hasher = match oid_len {
7143            40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
7144            64 => Self::Sha256(Sha256::new()),
7145            _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
7146        };
7147        hasher.update(header);
7148        Ok(hasher)
7149    }
7150
7151    fn update(&mut self, bytes: &[u8]) {
7152        match self {
7153            Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
7154            Self::Sha256(hasher) => hasher.update(bytes),
7155        }
7156    }
7157
7158    fn finish(self) -> String {
7159        let bytes = match self {
7160            Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
7161            Self::Sha256(hasher) => hasher.finalize().to_vec(),
7162        };
7163        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
7164    }
7165}
7166
7167fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
7168    let header = format!("blob {}\0", bytes.len());
7169    let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
7170    hasher.update(bytes);
7171    Ok(hasher.finish())
7172}
7173
7174fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
7175    let mut first = open_regular_file(path)?;
7176    let first_before = first
7177        .metadata()
7178        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7179    let mut raw_len = 0u64;
7180    let mut crlf_pairs = 0u64;
7181    let mut previous_was_cr = false;
7182    let mut every_lf_was_crlf = true;
7183    let mut buf = [0u8; 64 * 1024];
7184    loop {
7185        let read = first
7186            .read(&mut buf)
7187            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7188        if read == 0 {
7189            break;
7190        }
7191        raw_len = raw_len
7192            .checked_add(read as u64)
7193            .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
7194        for byte in &buf[..read] {
7195            if *byte == b'\n' {
7196                if previous_was_cr {
7197                    crlf_pairs += 1;
7198                } else {
7199                    every_lf_was_crlf = false;
7200                }
7201            }
7202            previous_was_cr = *byte == b'\r';
7203        }
7204    }
7205    let first_after = first
7206        .metadata()
7207        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7208    let current = std::fs::symlink_metadata(path)
7209        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7210    if raw_len != first_before.len()
7211        || !stable_file_metadata(&first_before, &first_after)
7212        || !stable_file_metadata(&first_after, &current)
7213    {
7214        bail!(
7215            "tracked file {} changed while line endings were inspected",
7216            path.display()
7217        );
7218    }
7219
7220    let normalized_len = raw_len
7221        .checked_sub(crlf_pairs)
7222        .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
7223    let header = format!("blob {normalized_len}\0");
7224    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7225    let mut second = open_regular_file(path)?;
7226    let second_before = second
7227        .metadata()
7228        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7229    if !stable_file_metadata(&first_after, &second_before) {
7230        bail!(
7231            "tracked file {} changed between line-ending checks",
7232            path.display()
7233        );
7234    }
7235    let mut pending_cr = false;
7236    loop {
7237        let read = second
7238            .read(&mut buf)
7239            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7240        if read == 0 {
7241            break;
7242        }
7243        for byte in &buf[..read] {
7244            if pending_cr {
7245                if *byte == b'\n' {
7246                    object.update(b"\n");
7247                    pending_cr = false;
7248                    continue;
7249                }
7250                object.update(b"\r");
7251                pending_cr = false;
7252            }
7253            if *byte == b'\r' {
7254                pending_cr = true;
7255            } else {
7256                object.update(std::slice::from_ref(byte));
7257            }
7258        }
7259    }
7260    if pending_cr {
7261        object.update(b"\r");
7262    }
7263    let second_after = second
7264        .metadata()
7265        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7266    let current = std::fs::symlink_metadata(path)
7267        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7268    if !stable_file_metadata(&second_before, &second_after)
7269        || !stable_file_metadata(&second_after, &current)
7270    {
7271        bail!(
7272            "tracked file {} changed while line endings were hashed",
7273            path.display()
7274        );
7275    }
7276    Ok((object.finish(), every_lf_was_crlf))
7277}
7278
7279fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7280    let mut options = OpenOptions::new();
7281    options.read(true);
7282    #[cfg(unix)]
7283    {
7284        use std::os::unix::fs::OpenOptionsExt;
7285        options.custom_flags(libc::O_NOFOLLOW);
7286    }
7287    let file = options
7288        .open(path)
7289        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7290    let metadata = file
7291        .metadata()
7292        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7293    if !metadata.is_file() {
7294        bail!("tracked path {} is not a regular file", path.display());
7295    }
7296    Ok(file)
7297}
7298
7299fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7300    if !same_file(left, right)
7301        || left.len() != right.len()
7302        || left.modified().ok() != right.modified().ok()
7303        || left.permissions() != right.permissions()
7304    {
7305        return false;
7306    }
7307    #[cfg(unix)]
7308    {
7309        use std::os::unix::fs::MetadataExt;
7310        left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
7311    }
7312    #[cfg(not(unix))]
7313    {
7314        left.created().ok() == right.created().ok()
7315    }
7316}
7317
7318/// How many paths one `check-attr` call is asked about.
7319///
7320/// `check-attr` answers with three NUL fields per attribute per path, so the
7321/// output is several times the input. A whole monorepo in one call is megabytes
7322/// in both directions, and the 30 second timeout below is a timeout on the
7323/// batch. Batching keeps each call small enough to be honest about.
7324const ATTRIBUTE_BATCH: usize = 1000;
7325
7326fn check_attributes(
7327    cwd: &Path,
7328    paths: impl IntoIterator<Item = PathBuf>,
7329) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7330    let paths = paths.into_iter().collect::<BTreeSet<_>>();
7331    if paths.is_empty() {
7332        return Ok(BTreeMap::new());
7333    }
7334    let ordered = paths.into_iter().collect::<Vec<_>>();
7335    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7336    for batch in ordered.chunks(ATTRIBUTE_BATCH) {
7337        values.extend(check_attribute_batch(cwd, batch)?);
7338    }
7339    Ok(values)
7340}
7341
7342fn check_attribute_batch(
7343    cwd: &Path,
7344    batch: &[PathBuf],
7345) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7346    const NAMES: [&str; 6] = [
7347        "filter",
7348        "working-tree-encoding",
7349        "ident",
7350        "text",
7351        "eol",
7352        "crlf",
7353    ];
7354    let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
7355    let mut input = String::new();
7356    for path in &paths {
7357        let path = path.to_str().ok_or_else(|| {
7358            spar_err!(
7359                "cannot inspect attributes for a non-UTF-8 path in {}",
7360                cwd.display()
7361            )
7362        })?;
7363        input.push_str(path);
7364        input.push('\0');
7365    }
7366    let argv = git_without_automation_argv(&[
7367        "check-attr",
7368        "-z",
7369        "--cached",
7370        "--stdin",
7371        "filter",
7372        "working-tree-encoding",
7373        "ident",
7374        "text",
7375        "eol",
7376        "crlf",
7377    ]);
7378    let output = proc::run_bytes(
7379        &argv,
7380        &ExecOpts::new()
7381            .cwd(cwd)
7382            .timeout_secs(30)
7383            .stdin(input)
7384            .stop_descendants(true),
7385    )?;
7386    if !output.is_empty() && !output.ends_with(&[0]) {
7387        bail!(
7388            "git returned an unterminated attribute result for {}",
7389            cwd.display()
7390        );
7391    }
7392    let fields = output
7393        .split(|byte| *byte == 0)
7394        .filter(|field| !field.is_empty())
7395        .collect::<Vec<_>>();
7396    if fields.len() != paths.len() * NAMES.len() * 3 {
7397        bail!(
7398            "git returned an unexpected attribute result for {}",
7399            cwd.display()
7400        );
7401    }
7402    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7403    for record in fields.chunks_exact(3) {
7404        let path = safe_git_path(record[0], "attribute")?;
7405        if !paths.contains(&path) {
7406            bail!(
7407                "git returned attributes for the wrong path in {}",
7408                cwd.display()
7409            );
7410        }
7411        let name = std::str::from_utf8(record[1])
7412            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
7413        let value = std::str::from_utf8(record[2])
7414            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
7415        values
7416            .entry(path)
7417            .or_default()
7418            .insert(name.to_string(), value.to_string());
7419    }
7420    if paths.iter().any(|path| {
7421        values
7422            .get(path)
7423            .is_none_or(|attributes| attributes.len() != NAMES.len())
7424    }) {
7425        bail!(
7426            "git omitted an attribute result for a tracked path in {}",
7427            cwd.display()
7428        );
7429    }
7430    Ok(values)
7431}
7432
7433fn attribute_is_active(value: Option<&String>) -> bool {
7434    !matches!(
7435        value.map(String::as_str),
7436        None | Some("unspecified") | Some("unset")
7437    )
7438}
7439
7440fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
7441    attribute_is_active(values.get("filter"))
7442        || attribute_is_active(values.get("working-tree-encoding"))
7443}
7444
7445/// The checkout settings a per-file line-ending decision depends on.
7446///
7447/// These are constant for a worktree, but the checks that read them run once
7448/// per tracked file, and each read was its own `git config` process. A
7449/// thousand-file repository with no `.gitattributes` takes every one of those
7450/// branches, which is half a minute of process spawning per scan, and the sweep
7451/// before a run scans every finished worktree more than once. Read them here,
7452/// once, and hand them down.
7453struct CheckoutConfig {
7454    autocrlf: Option<String>,
7455    eol: Option<String>,
7456    symlinks: Option<bool>,
7457}
7458
7459impl CheckoutConfig {
7460    fn read(cwd: &Path) -> Result<Self> {
7461        Ok(Self {
7462            autocrlf: git_config_value(cwd, "core.autocrlf")?,
7463            eol: git_config_value(cwd, "core.eol")?,
7464            symlinks: git_config_bool(cwd, "core.symlinks")?,
7465        })
7466    }
7467
7468    fn autocrlf_is_true(&self) -> bool {
7469        self.autocrlf.as_ref().is_some_and(|value| {
7470            matches!(
7471                value.to_ascii_lowercase().as_str(),
7472                "true" | "yes" | "on" | "1"
7473            )
7474        })
7475    }
7476
7477    fn eol_is(&self, wanted: &str) -> bool {
7478        self.eol
7479            .as_ref()
7480            .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
7481    }
7482}
7483
7484fn path_has_ambiguous_transform(
7485    config: &CheckoutConfig,
7486    values: &BTreeMap<String, String>,
7487) -> Result<bool> {
7488    if path_has_external_transform(values)
7489        || attribute_is_active(values.get("ident"))
7490        || attribute_is_active(values.get("crlf"))
7491    {
7492        return Ok(true);
7493    }
7494    let text = values.get("text").map(String::as_str);
7495    let eol = values.get("eol").map(String::as_str);
7496    if text == Some("auto") {
7497        return Ok(true);
7498    }
7499    if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
7500        || !matches!(
7501            eol,
7502            Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
7503        )
7504    {
7505        return Ok(true);
7506    }
7507    if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
7508        return Ok(config.autocrlf_is_true());
7509    }
7510    Ok(false)
7511}
7512
7513fn allows_expected_crlf(
7514    config: &CheckoutConfig,
7515    values: &BTreeMap<String, String>,
7516) -> Result<bool> {
7517    if path_has_external_transform(values)
7518        || attribute_is_active(values.get("ident"))
7519        || attribute_is_active(values.get("crlf"))
7520    {
7521        return Ok(false);
7522    }
7523    let text = values.get("text").map(String::as_str);
7524    let eol = values.get("eol").map(String::as_str);
7525    if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
7526        return Ok(false);
7527    }
7528    if eol == Some("crlf") {
7529        return Ok(true);
7530    }
7531    if text != Some("set") {
7532        return Ok(false);
7533    }
7534    if let Some(autocrlf) = config.autocrlf.as_deref() {
7535        match autocrlf.to_ascii_lowercase().as_str() {
7536            "true" | "yes" | "on" | "1" => return Ok(true),
7537            "input" => return Ok(false),
7538            _ => {}
7539        }
7540    }
7541    if config.eol_is("crlf") {
7542        return Ok(true);
7543    }
7544    #[cfg(windows)]
7545    if config.eol.is_none() || config.eol_is("native") {
7546        return Ok(true);
7547    }
7548    Ok(false)
7549}
7550
7551fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
7552    let argv = git_without_automation_argv(&["config", "--get", key]);
7553    let output = proc::exec(
7554        &argv,
7555        &ExecOpts::new()
7556            .cwd(cwd)
7557            .timeout_secs(30)
7558            .check(false)
7559            .stop_descendants(true),
7560    )?;
7561    match output.code {
7562        0 => Ok(Some(output.stdout.trim().to_string())),
7563        1 => Ok(None),
7564        _ => bail!(
7565            "could not read Git configuration in {}: {}",
7566            cwd.display(),
7567            output.stderr.trim()
7568        ),
7569    }
7570}
7571
7572fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
7573    let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
7574    let output = proc::exec(
7575        &argv,
7576        &ExecOpts::new()
7577            .cwd(cwd)
7578            .timeout_secs(30)
7579            .check(false)
7580            .stop_descendants(true),
7581    )?;
7582    match output.code {
7583        0 if output.stdout.trim() == "true" => Ok(Some(true)),
7584        0 if output.stdout.trim() == "false" => Ok(Some(false)),
7585        0 => bail!(
7586            "git returned an invalid boolean for {key} in {}",
7587            cwd.display()
7588        ),
7589        1 => Ok(None),
7590        _ => bail!(
7591            "could not read Git configuration in {}: {}",
7592            cwd.display(),
7593            output.stderr.trim()
7594        ),
7595    }
7596}
7597
7598#[cfg(unix)]
7599fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
7600    use std::os::unix::fs::PermissionsExt;
7601    if metadata.permissions().mode() & 0o111 == 0 {
7602        "100644".to_string()
7603    } else {
7604        "100755".to_string()
7605    }
7606}
7607
7608#[cfg(not(unix))]
7609fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
7610    "100644".to_string()
7611}
7612
7613fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
7614    let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
7615    if !listed.is_empty() && !listed.ends_with(&[0]) {
7616        bail!(
7617            "git returned an unterminated tree listing for {}",
7618            cwd.display()
7619        );
7620    }
7621    let mut entries = Vec::new();
7622    for record in listed
7623        .split(|byte| *byte == 0)
7624        .filter(|record| !record.is_empty())
7625    {
7626        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7627            bail!("git returned a malformed tree record for {}", cwd.display());
7628        };
7629        let fields = record[..tab]
7630            .split(|byte| *byte == b' ')
7631            .collect::<Vec<_>>();
7632        if fields.len() != 3 {
7633            bail!("git returned a malformed tree header for {}", cwd.display());
7634        }
7635        let mode = std::str::from_utf8(fields[0])
7636            .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
7637            .to_string();
7638        let oid = std::str::from_utf8(fields[2])
7639            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7640            .to_string();
7641        entries.push(IndexEntry {
7642            path: safe_git_path(&record[tab + 1..], "tree")?,
7643            mode,
7644            oid,
7645        });
7646    }
7647    Ok(entries)
7648}
7649
7650fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
7651    Ok(tree_entries(cwd, "HEAD")?
7652        .into_iter()
7653        .filter(|entry| entry.mode == "160000")
7654        .map(|entry| (entry.path, entry.oid))
7655        .collect())
7656}
7657
7658fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
7659    let head = head_gitlinks(cwd)?;
7660    let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
7661        .into_iter()
7662        .map(|link| (link.path, link.oid))
7663        .collect();
7664    let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
7665    paths.extend(index.keys().cloned());
7666    Ok(paths
7667        .into_iter()
7668        .filter(|path| head.get(path) != index.get(path))
7669        .collect())
7670}
7671
7672fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
7673    let path = parent.join(relative);
7674    let metadata = match std::fs::symlink_metadata(&path) {
7675        Ok(metadata) => metadata,
7676        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7677        Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7678    };
7679    if !metadata.is_dir() {
7680        bail!("the gitlink at {} is not a directory", path.display());
7681    }
7682    let canonical = std::fs::canonicalize(&path)
7683        .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
7684    if canonical != path {
7685        bail!(
7686            "the gitlink at {} resolves through a symlink",
7687            path.display()
7688        );
7689    }
7690    if !path.join(".git").exists() {
7691        let empty = std::fs::read_dir(&path)
7692            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
7693            .next()
7694            .is_none();
7695        if empty {
7696            return Ok(None);
7697        }
7698        bail!(
7699            "the uninitialized gitlink at {} contains local files",
7700            path.display()
7701        );
7702    }
7703    let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
7704    if inside.trim() != "true" {
7705        bail!("the gitlink at {} is not a worktree", path.display());
7706    }
7707    let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
7708    let top = std::fs::canonicalize(top.trim()).map_err(|e| {
7709        spar_err!(
7710            "could not resolve the gitlink top level at {}: {e}",
7711            path.display()
7712        )
7713    })?;
7714    if top != canonical {
7715        bail!(
7716            "the gitlink at {} belongs to a different worktree",
7717            path.display()
7718        );
7719    }
7720    Ok(Some(canonical))
7721}
7722
7723/// A Git entry the outer repository cannot account for.
7724///
7725/// A checkout Git itself reports as a nested repository is expected. Its
7726/// directory is recorded by identity in the untracked snapshot and keeps the
7727/// worktree from being released, so a reviewer that clones a reproduction into
7728/// ignored build output neither loses that clone nor fails the run over it.
7729/// The scan does not descend into one, because what is under it belongs to
7730/// that repository.
7731///
7732/// A Git entry Git does not report is the one worth stopping for. Running
7733/// `git init` inside a directory that already holds tracked files leaves
7734/// nothing in any listing while making every file written under it invisible
7735/// to the outer worktree, so SPAR can no longer prove what is there.
7736fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
7737    let root = std::fs::canonicalize(cwd)
7738        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7739    let mut allowed = BTreeSet::from([root.join(".git")]);
7740    let mut checkouts = BTreeSet::new();
7741    let mut repositories = vec![root.clone()];
7742    let mut visited = BTreeSet::new();
7743    while let Some(repository) = repositories.pop() {
7744        let canonical = std::fs::canonicalize(&repository)
7745            .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7746        if !visited.insert(canonical.clone()) {
7747            bail!("submodule recursion revisited {}", canonical.display());
7748        }
7749        for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
7750            let (path, nested) = untracked_record(&raw, "untracked")?;
7751            if nested {
7752                checkouts.insert(canonical.join(path));
7753            }
7754        }
7755        for link in gitlinks(&canonical)? {
7756            let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
7757                continue;
7758            };
7759            allowed.insert(submodule.join(".git"));
7760            repositories.push(submodule);
7761        }
7762    }
7763
7764    // A build deletes its own directories while this walks them. A directory
7765    // that is gone holds no Git entry, so skipping it answers the question
7766    // being asked; erroring instead ended runs whose worktrees were fine.
7767    let scan_root = root.clone();
7768    let mut directories = vec![root];
7769    while let Some(directory) = directories.pop() {
7770        let entries = match std::fs::read_dir(&directory) {
7771            Ok(entries) => entries,
7772            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7773            Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7774        };
7775        for entry in entries {
7776            let entry = match entry {
7777                Ok(entry) => entry,
7778                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7779                Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7780            };
7781            let path = entry.path();
7782            if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
7783                continue;
7784            }
7785            if entry.file_name() == OsStr::new(".git") {
7786                if !allowed.contains(&path) {
7787                    return Ok(Some(path));
7788                }
7789                continue;
7790            }
7791            let kind = match entry.file_type() {
7792                Ok(kind) => kind,
7793                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7794                Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7795            };
7796            if kind.is_dir() && !checkouts.contains(&path) {
7797                directories.push(path);
7798            }
7799        }
7800    }
7801    Ok(None)
7802}
7803
7804pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
7805    if let Some(path) = unexpected_nested_git_entry(cwd)? {
7806        bail!(
7807            "the worktree contains a Git entry at {} that Git does not report as a nested \
7808             checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
7809             prove they are unchanged.",
7810            path.display()
7811        );
7812    }
7813    let root = std::fs::canonicalize(cwd)
7814        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7815    let mut repositories = BTreeMap::new();
7816    let mut visited = BTreeSet::new();
7817    collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
7818    Ok(GitState { repositories })
7819}
7820
7821fn collect_git_state(
7822    repository: &Path,
7823    prefix: &Path,
7824    visited: &mut BTreeSet<PathBuf>,
7825    repositories: &mut BTreeMap<PathBuf, RepositoryState>,
7826) -> Result<()> {
7827    let canonical = std::fs::canonicalize(repository)
7828        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7829    if !visited.insert(canonical.clone()) {
7830        bail!("submodule recursion revisited {}", canonical.display());
7831    }
7832    let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7833    let head = head.trim().to_string();
7834    if head.is_empty() {
7835        bail!("git returned an empty head for {}", repository.display());
7836    }
7837    let unsafe_index_flags = unsafe_index_flags(repository)?;
7838    let tracked = tracked_entries(repository)?;
7839    let gitlinks = gitlinks(repository)?;
7840    if repositories
7841        .insert(
7842            prefix.to_path_buf(),
7843            RepositoryState {
7844                head,
7845                unsafe_index_flags,
7846                tracked,
7847                gitlinks: gitlinks
7848                    .iter()
7849                    .map(|link| (link.path.clone(), link.oid.clone()))
7850                    .collect(),
7851            },
7852        )
7853        .is_some()
7854    {
7855        bail!("Git state contains duplicate repository path {:?}", prefix);
7856    }
7857
7858    for link in gitlinks {
7859        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7860            continue;
7861        };
7862        collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
7863    }
7864    Ok(())
7865}
7866
7867fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
7868    let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
7869    if !listed.is_empty() && !listed.ends_with(&[0]) {
7870        bail!(
7871            "git returned an unterminated index-flag listing for {}",
7872            cwd.display()
7873        );
7874    }
7875    let mut unsafe_records = Vec::new();
7876    for record in listed
7877        .split(|byte| *byte == 0)
7878        .filter(|record| !record.is_empty())
7879    {
7880        if record.len() < 3 || record[1] != b' ' {
7881            bail!(
7882                "git returned a malformed index-flag record for {}",
7883                cwd.display()
7884            );
7885        }
7886        if record[0] != b'H' {
7887            unsafe_records.extend_from_slice(record);
7888            unsafe_records.push(0);
7889        }
7890    }
7891    Ok(unsafe_records)
7892}
7893
7894pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
7895    safe_git_state(cwd).map(|_| ())
7896}
7897
7898pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
7899    let state = git_state(cwd)?;
7900    if let Some((path, _repository)) = state
7901        .repositories
7902        .iter()
7903        .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
7904    {
7905        let label = if path.as_os_str().is_empty() {
7906            cwd.to_path_buf()
7907        } else {
7908            cwd.join(path)
7909        };
7910        bail!(
7911            "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
7912             SPAR cannot prove the working files are unchanged, so it was kept.",
7913            label.display()
7914        );
7915    }
7916    Ok(state)
7917}
7918
7919fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
7920    repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
7921}
7922
7923fn repository_has_recoverable_work_with(
7924    cwd: &Path,
7925    include_ignored: bool,
7926    disposable: Disposable,
7927) -> Result<bool> {
7928    if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
7929        return Ok(true);
7930    }
7931    let mut visited = BTreeSet::new();
7932    repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
7933}
7934
7935fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
7936    let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
7937    let git_dir = PathBuf::from(git_dir.trim());
7938    let git_dir = if git_dir.is_absolute() {
7939        git_dir
7940    } else {
7941        cwd.join(git_dir)
7942    };
7943    let git_dir = std::fs::canonicalize(&git_dir)
7944        .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
7945    match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
7946        Ok(_) => return Ok(true),
7947        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7948        Err(error) => {
7949            return Err(spar_err!(
7950                "could not inspect per-worktree configuration in {}: {error}",
7951                git_dir.display()
7952            ))
7953        }
7954    }
7955
7956    let orig_head = git_dir.join("ORIG_HEAD");
7957    match std::fs::symlink_metadata(&orig_head) {
7958        Ok(metadata) if metadata.is_file() => {
7959            let oid = std::fs::read_to_string(&orig_head)
7960                .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
7961            let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
7962                return Ok(true);
7963            };
7964            if !commit_has_shared_ref(cwd, &commit)? {
7965                return Ok(true);
7966            }
7967        }
7968        Ok(_) => return Ok(true),
7969        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7970        Err(error) => {
7971            return Err(spar_err!(
7972                "could not inspect {}: {error}",
7973                orig_head.display()
7974            ))
7975        }
7976    }
7977
7978    let edit_message = git_dir.join("COMMIT_EDITMSG");
7979    match std::fs::symlink_metadata(&edit_message) {
7980        Ok(metadata) if metadata.is_file() => {
7981            let draft = std::fs::read(&edit_message)
7982                .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
7983            if draft != head_commit_message(cwd)? {
7984                return Ok(true);
7985            }
7986        }
7987        Ok(_) => return Ok(true),
7988        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
7989        Err(error) => {
7990            return Err(spar_err!(
7991                "could not inspect {}: {error}",
7992                edit_message.display()
7993            ))
7994        }
7995    }
7996
7997    if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
7998        return Ok(true);
7999    }
8000
8001    let local_refs = run_git_bytes(
8002        cwd,
8003        &[
8004            "for-each-ref",
8005            "--format=%(refname)",
8006            "refs/worktree",
8007            "refs/bisect",
8008            "refs/rewritten",
8009        ],
8010    )?;
8011    if !local_refs.is_empty() {
8012        return Ok(true);
8013    }
8014
8015    // `FETCH_HEAD` only names what a `git fetch` in this worktree brought
8016    // down. The objects live in the shared store and the next fetch rewrites
8017    // it, so an agent that fetched once must not keep the checkout forever.
8018    for entry in std::fs::read_dir(&git_dir)
8019        .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
8020    {
8021        let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
8022        let known = matches!(
8023            entry.file_name().to_str(),
8024            Some(
8025                "HEAD"
8026                    | "ORIG_HEAD"
8027                    | "COMMIT_EDITMSG"
8028                    | "FETCH_HEAD"
8029                    | "commondir"
8030                    | "gitdir"
8031                    | "index"
8032                    | "logs"
8033                    | "refs"
8034            )
8035        );
8036        if !known {
8037            return Ok(true);
8038        }
8039    }
8040
8041    let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8042    if !commit_has_shared_ref(cwd, head.trim())? {
8043        return Ok(true);
8044    }
8045    Ok(false)
8046}
8047
8048fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
8049    let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
8050    let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
8051        bail!(
8052            "git returned a commit without a message separator in {}",
8053            cwd.display()
8054        );
8055    };
8056    Ok(commit[split + 2..].to_vec())
8057}
8058
8059fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
8060    let metadata = match std::fs::symlink_metadata(logs) {
8061        Ok(metadata) => metadata,
8062        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
8063        Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
8064    };
8065    if !metadata.is_dir() {
8066        return Ok(true);
8067    }
8068    let mut files = Vec::new();
8069    let mut directories = vec![logs.to_path_buf()];
8070    while let Some(directory) = directories.pop() {
8071        for entry in std::fs::read_dir(&directory)
8072            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
8073        {
8074            let entry =
8075                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
8076            let path = entry.path();
8077            let kind = entry
8078                .file_type()
8079                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
8080            if kind.is_dir() {
8081                directories.push(path);
8082            } else if kind.is_file() {
8083                files.push(path);
8084            } else {
8085                return Ok(true);
8086            }
8087        }
8088    }
8089
8090    let mut commits = BTreeSet::new();
8091    for path in files {
8092        if !collect_reflog_commits(cwd, &path, &mut commits)? {
8093            return Ok(true);
8094        }
8095    }
8096    for commit in commits {
8097        if !commit_has_shared_ref(cwd, &commit)? {
8098            return Ok(true);
8099        }
8100    }
8101    Ok(false)
8102}
8103
8104/// Every commit named by one ref's common reflog must survive deletion of that
8105/// ref. Ancestors of a durable current tip survive with the tip; divergent
8106/// entries need another shared ref of their own.
8107fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
8108    let common = common_git_dir(cwd)?;
8109    let reflog = common.join("logs").join(refname);
8110    let metadata = match std::fs::symlink_metadata(&reflog) {
8111        Ok(metadata) => metadata,
8112        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
8113        Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
8114    };
8115    if !metadata.is_file() {
8116        return Ok(false);
8117    }
8118    let mut commits = BTreeSet::new();
8119    if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
8120        return Ok(false);
8121    }
8122    for commit in commits {
8123        if is_ancestor(cwd, &commit, durable_tip)?
8124            || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
8125        {
8126            continue;
8127        }
8128        return Ok(false);
8129    }
8130    Ok(true)
8131}
8132
8133fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
8134    let file = std::fs::File::open(path)
8135        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8136    for line in std::io::BufReader::new(file).lines() {
8137        let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8138        let mut fields = line.splitn(3, ' ');
8139        let Some(old) = fields.next() else {
8140            return Ok(false);
8141        };
8142        let Some(new) = fields.next() else {
8143            return Ok(false);
8144        };
8145        if fields.next().is_none() {
8146            return Ok(false);
8147        }
8148        for oid in [old, new] {
8149            if oid.bytes().all(|byte| byte == b'0') {
8150                continue;
8151            }
8152            let Some(commit) = resolve_optional_commit(cwd, oid)? else {
8153                return Ok(false);
8154            };
8155            commits.insert(commit);
8156        }
8157    }
8158    Ok(true)
8159}
8160
8161fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
8162    let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
8163    let path = PathBuf::from(raw.trim());
8164    let path = if path.is_absolute() {
8165        path
8166    } else {
8167        cwd.join(path)
8168    };
8169    std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
8170}
8171
8172fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
8173    let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
8174    let output = proc::exec(
8175        &argv,
8176        &ExecOpts::new()
8177            .cwd(cwd)
8178            .timeout_secs(30)
8179            .check(false)
8180            .stop_descendants(true),
8181    )?;
8182    match output.code {
8183        0 => Ok(true),
8184        1 => Ok(false),
8185        _ => bail!("{}", proc::failure_message(&argv, &output)),
8186    }
8187}
8188
8189fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
8190    let commit = format!("{oid}^{{commit}}");
8191    let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
8192    let output = proc::exec(
8193        &argv,
8194        &ExecOpts::new()
8195            .cwd(cwd)
8196            .timeout_secs(30)
8197            .check(false)
8198            .stop_descendants(true),
8199    )?;
8200    if output.code != 0 {
8201        return Ok(None);
8202    }
8203    let oid = output.stdout.trim();
8204    if oid.is_empty() {
8205        return Ok(None);
8206    }
8207    Ok(Some(oid.to_string()))
8208}
8209
8210fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
8211    commit_has_shared_ref_except(cwd, oid, None)
8212}
8213
8214fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
8215    let contains = format!("--contains={oid}");
8216    let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
8217    Ok(shared.split(|byte| *byte == b'\n').any(|record| {
8218        !record.is_empty()
8219            && !record.starts_with(b"refs/worktree/")
8220            && !record.starts_with(b"refs/bisect/")
8221            && !record.starts_with(b"refs/rewritten/")
8222            && exclude.is_none_or(|excluded| record != excluded.as_bytes())
8223    }))
8224}
8225
8226/// Whether removing the worktree would take away an untracked file somebody
8227/// might want back.
8228///
8229/// Ordinary untracked files always count. So does an ignored file outside the
8230/// known build and cache directories, because an ignored path is only a path
8231/// Git was told not to track, which is where a local `.env` lives as readily as
8232/// compiler output.
8233///
8234/// Recognized build and cache output does not. A managed commit already leaves
8235/// it out rather than treating it as work, and the command that wrote it writes
8236/// it again. Counting it kept every worktree whose tests or build had run,
8237/// which is nearly all of them, so a merged pull request still left its
8238/// checkout behind. A repository nested in that output is somebody else's
8239/// history and counts whatever it sits under.
8240fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
8241    let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
8242    for raw in &ordinary {
8243        let (path, _) = untracked_record(raw, "untracked")?;
8244        if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
8245            continue;
8246        }
8247        return Ok(true);
8248    }
8249    let listed = untracked_listing(
8250        cwd,
8251        &[
8252            "ls-files",
8253            "--others",
8254            "--ignored",
8255            "--exclude-standard",
8256            "-z",
8257        ],
8258    )?;
8259    for raw in listed {
8260        let (path, nested) = untracked_record(&raw, "ignored")?;
8261        // A repository cloned into the output keeps the checkout whatever the
8262        // mode: its objects exist nowhere else, which is the one thing a
8263        // rebuild from origin cannot replace.
8264        if nested {
8265            return Ok(true);
8266        }
8267        if disposable == Disposable::BuildOutput
8268            && !is_generated_artifact(&path)
8269            && !is_desktop_metadata(&path)
8270        {
8271            return Ok(true);
8272        }
8273    }
8274    Ok(false)
8275}
8276
8277fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8278    let listed = run_git_bytes(cwd, args)?;
8279    if !listed.is_empty() && !listed.ends_with(&[0]) {
8280        bail!(
8281            "git returned an unterminated untracked-file list for {}",
8282            cwd.display()
8283        );
8284    }
8285    Ok(listed
8286        .split(|byte| *byte == 0)
8287        .filter(|raw| !raw.is_empty())
8288        .map(|raw| raw.to_vec())
8289        .collect())
8290}
8291
8292fn repository_has_recoverable_work_inner(
8293    cwd: &Path,
8294    include_ignored: bool,
8295    disposable: Disposable,
8296    visited: &mut BTreeSet<PathBuf>,
8297) -> Result<bool> {
8298    let canonical = std::fs::canonicalize(cwd)
8299        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8300    if !visited.insert(canonical.clone()) {
8301        bail!("submodule recursion revisited {}", canonical.display());
8302    }
8303    if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
8304        return Ok(true);
8305    }
8306    if !unsafe_index_flags(cwd)?.is_empty() {
8307        return Ok(true);
8308    }
8309    if attributes_may_be_modified(cwd)? {
8310        return Ok(true);
8311    }
8312    if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
8313        return Ok(true);
8314    }
8315    if include_ignored {
8316        let index = index_entries(cwd)?
8317            .into_iter()
8318            .map(|entry| (entry.path, (entry.mode, entry.oid)))
8319            .collect::<BTreeMap<_, _>>();
8320        let head = tree_entries(cwd, "HEAD")?
8321            .into_iter()
8322            .map(|entry| (entry.path, (entry.mode, entry.oid)))
8323            .collect::<BTreeMap<_, _>>();
8324        if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
8325            return Ok(true);
8326        }
8327        let tracked = tracked_entries(cwd)?;
8328        let effective = check_attributes(cwd, tracked.keys().cloned())?;
8329        let config = CheckoutConfig::read(cwd)?;
8330        for (path, entry) in tracked {
8331            let Some(worktree) = entry.worktree else {
8332                return Ok(true);
8333            };
8334            let attributes = effective.get(&path).ok_or_else(|| {
8335                spar_err!("git omitted attributes for {}", cwd.join(&path).display())
8336            })?;
8337            if path_has_ambiguous_transform(&config, attributes)? {
8338                return Ok(true);
8339            }
8340            let symlink_file = entry.index_mode == "120000"
8341                && worktree.mode == "100644"
8342                && worktree.raw_oid == entry.index_oid
8343                && config.symlinks == Some(false);
8344            if worktree.mode != entry.index_mode && !symlink_file {
8345                return Ok(true);
8346            }
8347            if entry.index_mode == "120000" {
8348                if worktree.raw_oid != entry.index_oid {
8349                    return Ok(true);
8350                }
8351                continue;
8352            }
8353            #[cfg(unix)]
8354            {
8355                let expected = if entry.index_mode == "100755" {
8356                    0o755
8357                } else {
8358                    0o644
8359                };
8360                if worktree.permissions != expected {
8361                    return Ok(true);
8362                }
8363            }
8364            if allows_expected_crlf(&config, attributes)? {
8365                let (normalized, every_lf_was_crlf) =
8366                    normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
8367                if !every_lf_was_crlf || normalized != entry.index_oid {
8368                    return Ok(true);
8369                }
8370            } else if worktree.raw_oid != entry.index_oid {
8371                return Ok(true);
8372            }
8373        }
8374    } else {
8375        let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
8376        if !run_git_bytes(cwd, &args)?.is_empty() {
8377            return Ok(true);
8378        }
8379    }
8380    for link in gitlinks(cwd)? {
8381        let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
8382            continue;
8383        };
8384        // Git stores a linked worktree's initialized submodule objects under
8385        // that worktree's administrative directory. Ordinary removal cannot
8386        // prove a local submodule commit exists anywhere else, even when both
8387        // working trees look clean.
8388        if include_ignored {
8389            return Ok(true);
8390        }
8391        let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8392        if head.trim() != link.oid {
8393            return Ok(true);
8394        }
8395        if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
8396        {
8397            return Ok(true);
8398        }
8399    }
8400    Ok(false)
8401}
8402
8403pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
8404    repository_has_recoverable_work(cwd, false)
8405}
8406
8407fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
8408    let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
8409    Ok(!run_git_bytes(cwd, &args)?.is_empty())
8410}
8411
8412#[cfg(unix)]
8413fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8414    use std::os::unix::ffi::OsStringExt;
8415    Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
8416}
8417
8418#[cfg(not(unix))]
8419fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8420    String::from_utf8(raw.to_vec())
8421        .map(PathBuf::from)
8422        .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
8423}
8424
8425/// Fingerprint the directory of a nested repository without reading inside it.
8426///
8427/// The files under it are that repository's, not this one's. They are recorded
8428/// against its own baseline whenever SPAR works there, and a run of its own may
8429/// legitimately add or remove entries while this call is in flight, so the
8430/// volatile directory fields stay out of the fingerprint. Identity and type
8431/// remain, which is what makes deleting the checkout, or replacing it with a
8432/// file, observable from the outer worktree.
8433/// What a path Git listed looks like once it is no longer there.
8434///
8435/// A reviewer building the project deletes and rewrites its output constantly,
8436/// so a path can go between the listing that named it and the stat that
8437/// measures it. Treating that as an unreadable worktree ended runs that had
8438/// nothing wrong with them. The record stays in the snapshot rather than being
8439/// dropped, so a path that vanishes cannot pass for one that was never there.
8440fn vanished_untracked_file() -> UntrackedFile {
8441    UntrackedFile {
8442        kind: 0,
8443        len: 0,
8444        modified: None,
8445        created: None,
8446        readonly: false,
8447        symlink_target: None,
8448        #[cfg(unix)]
8449        device: 0,
8450        #[cfg(unix)]
8451        inode: 0,
8452        #[cfg(unix)]
8453        mode: 0,
8454        #[cfg(unix)]
8455        change_seconds: 0,
8456        #[cfg(unix)]
8457        change_nanoseconds: 0,
8458    }
8459}
8460
8461fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
8462    let metadata = match std::fs::symlink_metadata(path) {
8463        Ok(metadata) => metadata,
8464        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8465        Err(e) => {
8466            return Err(spar_err!(
8467                "could not inspect the nested repository at {}: {e}",
8468                path.display()
8469            ))
8470        }
8471    };
8472    if !metadata.is_dir() {
8473        bail!(
8474            "git reported {} as a nested repository, but it is not a directory",
8475            path.display()
8476        );
8477    }
8478    if std::fs::symlink_metadata(path.join(".git")).is_err() {
8479        bail!(
8480            "git reported {} as a nested repository, but it has no Git entry",
8481            path.display()
8482        );
8483    }
8484    #[cfg(unix)]
8485    {
8486        use std::os::unix::fs::MetadataExt;
8487        Ok(UntrackedFile {
8488            kind: 3,
8489            len: 0,
8490            modified: None,
8491            created: metadata.created().ok(),
8492            readonly: metadata.permissions().readonly(),
8493            symlink_target: None,
8494            device: metadata.dev(),
8495            inode: metadata.ino(),
8496            mode: metadata.mode(),
8497            change_seconds: 0,
8498            change_nanoseconds: 0,
8499        })
8500    }
8501    #[cfg(not(unix))]
8502    {
8503        Ok(UntrackedFile {
8504            kind: 3,
8505            len: 0,
8506            modified: None,
8507            created: metadata.created().ok(),
8508            readonly: metadata.permissions().readonly(),
8509            symlink_target: None,
8510        })
8511    }
8512}
8513
8514fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
8515    let metadata = match std::fs::symlink_metadata(path) {
8516        Ok(metadata) => metadata,
8517        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8518        Err(e) => {
8519            return Err(spar_err!(
8520                "could not inspect untracked file {}: {e}",
8521                path.display()
8522            ))
8523        }
8524    };
8525    let kind = if metadata.file_type().is_symlink() {
8526        2
8527    } else if metadata.is_file() {
8528        1
8529    } else {
8530        bail!(
8531            "untracked path {} is not a regular file or symlink",
8532            path.display()
8533        );
8534    };
8535    let symlink_target = if kind == 2 {
8536        match std::fs::read_link(path) {
8537            Ok(target) => Some(os_str_bytes(target.as_os_str())?),
8538            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
8539                return Ok(vanished_untracked_file())
8540            }
8541            Err(e) => {
8542                return Err(spar_err!(
8543                    "could not read untracked symlink {}: {e}",
8544                    path.display()
8545                ))
8546            }
8547        }
8548    } else {
8549        None
8550    };
8551    #[cfg(unix)]
8552    {
8553        use std::os::unix::fs::MetadataExt;
8554        Ok(UntrackedFile {
8555            kind,
8556            len: metadata.len(),
8557            modified: metadata.modified().ok(),
8558            created: metadata.created().ok(),
8559            readonly: metadata.permissions().readonly(),
8560            symlink_target,
8561            device: metadata.dev(),
8562            inode: metadata.ino(),
8563            mode: metadata.mode(),
8564            change_seconds: metadata.ctime(),
8565            change_nanoseconds: metadata.ctime_nsec(),
8566        })
8567    }
8568    #[cfg(not(unix))]
8569    {
8570        Ok(UntrackedFile {
8571            kind,
8572            len: metadata.len(),
8573            modified: metadata.modified().ok(),
8574            created: metadata.created().ok(),
8575            readonly: metadata.permissions().readonly(),
8576            symlink_target,
8577        })
8578    }
8579}
8580
8581#[cfg(unix)]
8582fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8583    use std::os::unix::ffi::OsStrExt;
8584    Ok(value.as_bytes().to_vec())
8585}
8586
8587#[cfg(not(unix))]
8588fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8589    value
8590        .to_str()
8591        .map(|value| value.as_bytes().to_vec())
8592        .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
8593}
8594
8595#[cfg(unix)]
8596fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8597    use std::os::unix::fs::MetadataExt;
8598    right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
8599}
8600
8601#[cfg(not(unix))]
8602fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8603    right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
8604}
8605
8606#[derive(Debug, Clone, serde::Serialize, Deserialize)]
8607pub struct BranchRecord {
8608    pub kind: String,
8609    pub number: i64,
8610}
8611
8612/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
8613/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
8614pub fn review_ref(number: i64) -> String {
8615    format!("refs/spar/pr-{number}")
8616}
8617
8618pub fn is_finished(state: &str) -> bool {
8619    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
8620}
8621
8622/// Write text through a temporary file and rename, so a kill cannot leave a
8623/// truncated file behind.
8624///
8625/// The follow-up queue is the one file spar rewrites in place rather than
8626/// appends to, and a truncated queue is lost work: what it held was never
8627/// written anywhere else.
8628pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
8629    if let Some(parent) = path.parent() {
8630        std::fs::create_dir_all(parent)
8631            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
8632    }
8633    // The extension defaults to `json` so `clear_state`, which removes a
8634    // leftover `pr-N.json.tmp` by name, keeps finding the one this wrote.
8635    let tmp = path.with_extension(format!(
8636        "{}.tmp",
8637        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
8638    ));
8639    std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
8640    std::fs::rename(&tmp, path)
8641        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
8642    Ok(())
8643}
8644
8645/// Whether a follow-up file already has an entry under this exact title.
8646///
8647/// Whole heading lines, case insensitively. A substring check answered yes
8648/// for "Retry loop" against "## Retry loop never terminates", which is a
8649/// different defect, and the new one was dropped with a line saying it was
8650/// already noted.
8651pub fn has_heading(text: &str, title: &str) -> bool {
8652    let wanted = title.trim().to_lowercase();
8653    if wanted.is_empty() {
8654        return false;
8655    }
8656    text.lines()
8657        .filter_map(|line| line.trim().strip_prefix("## "))
8658        .any(|heading| heading.trim().to_lowercase() == wanted)
8659}
8660
8661/// Write JSON through a temporary file and rename, so a kill cannot leave a
8662/// truncated state file behind.
8663pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
8664    write_text_atomic(path, &serde_json::to_string_pretty(value)?)
8665}
8666
8667/// One row of `gh pr list --json number,url,title,baseRefName,closingIssuesReferences`.
8668#[derive(Deserialize)]
8669#[serde(rename_all = "camelCase")]
8670pub struct LinkedPrRow {
8671    number: i64,
8672    #[serde(default)]
8673    url: String,
8674    #[serde(default)]
8675    title: String,
8676    #[serde(default)]
8677    closing_issues_references: Vec<IssueRef>,
8678}
8679
8680/// The listed pull requests, or `None` when the payload could not be read.
8681///
8682/// Separated from the gh call so the real payload shape can be tested, and kept
8683/// distinct from an empty list, because silently failing to parse would look
8684/// exactly like "no pull request exists", which is the answer that makes spar
8685/// implement over the top of somebody's work.
8686pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
8687    serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
8688}
8689
8690/// Among the listed pull requests, the first that would close `issue`.
8691pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
8692    rows.iter()
8693        .find(|row| {
8694            row.closing_issues_references
8695                .iter()
8696                .any(|linked| linked.number == issue)
8697        })
8698        .map(|row| PrRef {
8699            number: row.number,
8700            url: row.url.clone(),
8701            title: row.title.clone(),
8702        })
8703}
8704
8705/// Both halves together, for the tests that hold the real payload.
8706pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
8707    pick_linked_pr(&linked_pr_rows(json)?, issue)
8708}
8709
8710/// Flatten whatever `gh api --paginate` printed into a list of comments.
8711///
8712/// Current gh merges array pages into one array. Older builds concatenated one
8713/// document per page. A streaming parser reads either, and unlike splitting the
8714/// text on a bracket pair it cannot be fooled by a comment body that happens to
8715/// contain one, which would otherwise make a resume silently start over.
8716pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
8717    if text.trim().is_empty() {
8718        return Err(spar_err!("GitHub returned no comment data"));
8719    }
8720    let mut out = Vec::new();
8721    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8722        match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
8723            Value::Array(items) => out.extend(items),
8724            _ => return Err(spar_err!("unexpected non-array comment page")),
8725        }
8726    }
8727    Ok(out)
8728}
8729
8730pub fn parse_comment_pages(text: &str) -> Vec<Value> {
8731    let mut out = Vec::new();
8732    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8733        match value {
8734            Ok(Value::Array(items)) => out.extend(items),
8735            Ok(other) => out.push(other),
8736            Err(_) => break,
8737        }
8738    }
8739    out
8740}
8741
8742/// Whether a comment is a state block spar itself wrote.
8743///
8744/// The body has to open with the marker, so a person quoting it in a sentence
8745/// is not one; it has to parse, so a half-quoted block is not one; and it has
8746/// to be the viewer's own comment, the way `comments.rs` already tells spar's
8747/// writes apart. Anything less and `write_pr_state` patches somebody's comment
8748/// with JSON, and `spar clean --pr-state` later deletes it.
8749pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
8750    body.trim_start().starts_with(STATE_MARKER)
8751        && !viewer.trim().is_empty()
8752        && author.trim().eq_ignore_ascii_case(viewer.trim())
8753        && parse_state_comment(body).is_some()
8754}
8755
8756/// Extract the payload from a state comment. The marker is followed by JSON and
8757/// terminated with `-->`.
8758pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
8759    let marker = body.find(STATE_MARKER)?;
8760    let start = body[marker..].find('{')? + marker;
8761    let end = body.rfind('}')?;
8762    if end <= start {
8763        return None;
8764    }
8765    match serde_json::from_str(&body[start..=end]) {
8766        Ok(state) => Some(state),
8767        Err(_) => {
8768            logdim!("found a spar state comment but could not parse it");
8769            None
8770        }
8771    }
8772}
8773
8774fn choose_state_for_head(
8775    candidates: Vec<PersistedState>,
8776    actual_head: &str,
8777) -> Option<PersistedState> {
8778    let matching: Vec<PersistedState> = candidates
8779        .iter()
8780        .filter(|state| state.pr_head == actual_head)
8781        .cloned()
8782        .collect();
8783    if !matching.is_empty() {
8784        return newest_state(matching);
8785    }
8786    newest_state(candidates)
8787}
8788
8789fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
8790    candidates.into_iter().reduce(|best, candidate| {
8791        if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
8792            candidate
8793        } else {
8794            // The local candidate is supplied first. Keeping the first exact
8795            // tie recovers correctly from a local write followed by a failed
8796            // pull request state update, including legacy states with no
8797            // checkpoint field.
8798            best
8799        }
8800    })
8801}
8802
8803/// Where this binary lives, so `git filter-branch` can call back into it.
8804///
8805/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
8806/// tests, whose `current_exe` is the test harness rather than spar, and for
8807/// anyone who ships spar behind a wrapper script.
8808pub fn self_binary() -> Result<PathBuf> {
8809    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
8810        let path = PathBuf::from(path);
8811        if proc::is_executable(&path) {
8812            return Ok(path);
8813        }
8814        bail!(
8815            "SPAR_SELF_BIN is set to {}, which is not executable",
8816            path.display()
8817        );
8818    }
8819    std::env::current_exe()
8820        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
8821}
8822
8823fn bool_env(value: bool) -> &'static str {
8824    if value {
8825        "1"
8826    } else {
8827        "0"
8828    }
8829}
8830
8831/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
8832/// shell command, and an install path with a space in it is not exotic.
8833pub fn sh_quote(text: &str) -> String {
8834    format!("'{}'", text.replace('\'', r"'\''"))
8835}
8836
8837/// Style rules for the `scrub-filter` subcommand, which runs in a child process
8838/// spawned by git and so cannot see the parent's config.
8839pub fn style_from_env() -> Style {
8840    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
8841    Style {
8842        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
8843        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
8844        ..Style::permissive()
8845    }
8846}
8847
8848#[cfg(test)]
8849mod tests {
8850    use super::*;
8851    use crate::config::StateStore;
8852    use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
8853    use std::process::Command;
8854
8855    fn repo_for_titles() -> Repo {
8856        Repo {
8857            root: PathBuf::from("/nonexistent"),
8858            style: Style::default(),
8859            branch_prefix: String::new(),
8860            state_store: StateStore::Local,
8861            followups: crate::config::Followups::Issues,
8862            drafts: Drafts::Never,
8863            fork_when_denied: true,
8864            setup: CheckCommand::default(),
8865            fork: OnceLock::new(),
8866            viewer: OnceLock::new(),
8867            checkpoints: Mutex::new(BTreeMap::new()),
8868            writes: WriteStats::default(),
8869        }
8870    }
8871
8872    #[test]
8873    fn write_results_accumulate_for_the_run() {
8874        let repo = repo_for_titles();
8875
8876        let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
8877        let _: std::result::Result<(), ()> = repo.record_write(Err(()));
8878
8879        assert_eq!(
8880            WriteSummary {
8881                attempted: 2,
8882                failed: 1,
8883            },
8884            repo.write_summary()
8885        );
8886    }
8887
8888    #[test]
8889    fn only_failed_write_preflights_join_the_summary() {
8890        let repo = repo_for_titles();
8891
8892        let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
8893        let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
8894
8895        assert_eq!(
8896            WriteSummary {
8897                attempted: 1,
8898                failed: 1,
8899            },
8900            repo.write_summary()
8901        );
8902    }
8903
8904    #[test]
8905    fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
8906        let repo = repo_for_titles();
8907
8908        assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
8909        assert_eq!(
8910            WriteSummary {
8911                attempted: 1,
8912                failed: 1,
8913            },
8914            repo.write_summary()
8915        );
8916    }
8917
8918    #[test]
8919    fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
8920        let mut repo = repo_for_titles();
8921        repo.followups = Followups::Local;
8922
8923        assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
8924        assert_eq!(WriteSummary::default(), repo.write_summary());
8925    }
8926
8927    #[test]
8928    fn a_failed_remote_state_read_stops_before_state_mutation() {
8929        let root = std::env::temp_dir().join(format!(
8930            "spar-state-preflight-{}-{}",
8931            std::process::id(),
8932            std::time::SystemTime::now()
8933                .duration_since(std::time::UNIX_EPOCH)
8934                .unwrap()
8935                .as_nanos()
8936        ));
8937        std::fs::create_dir_all(&root).unwrap();
8938        let _fixture = ReviewFixture { root: root.clone() };
8939        let mut repo = repo_for_titles();
8940        repo.root = root;
8941        repo.state_store = StateStore::Both;
8942        let state = PersistedState {
8943            version: 1,
8944            checkpoint: 4,
8945            round: 2,
8946            next_actor: "a".into(),
8947            status: Status::Pending,
8948            pr_head: "abc123".into(),
8949            ledger: Ledger::new(),
8950            filed: Vec::new(),
8951            open_findings: Vec::new(),
8952            disputes: Vec::new(),
8953            noted: Vec::new(),
8954        };
8955
8956        let error = repo
8957            .write_state_after_remote_read(
8958                7,
8959                &state,
8960                Err(crate::error::SparError::new("state comments unavailable")),
8961            )
8962            .unwrap_err();
8963
8964        assert!(error.to_string().contains("state comments unavailable"));
8965        assert!(!repo.state_path(7).exists());
8966        assert_eq!(0, repo.remembered_checkpoint(7));
8967        assert_eq!(
8968            WriteSummary {
8969                attempted: 1,
8970                failed: 1,
8971            },
8972            repo.write_summary()
8973        );
8974    }
8975
8976    #[test]
8977    fn only_known_build_and_cache_directories_are_generated_artifacts() {
8978        assert!(is_generated_artifact(Path::new("target/debug/artifact")));
8979        assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
8980        assert!(is_generated_artifact(Path::new(
8981            "package/node_modules/dependency/file.js"
8982        )));
8983        assert!(!is_generated_artifact(Path::new(
8984            "distribution/required-package.js"
8985        )));
8986        assert!(!is_generated_artifact(Path::new(
8987            "generated/required-fixture.txt"
8988        )));
8989        assert!(!is_generated_artifact(Path::new("local.env")));
8990    }
8991
8992    #[test]
8993    fn only_finder_state_files_are_desktop_metadata() {
8994        assert!(is_desktop_metadata(Path::new(".DS_Store")));
8995        assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
8996        assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
8997        assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
8998    }
8999
9000    struct ReviewFixture {
9001        root: PathBuf,
9002    }
9003
9004    impl Drop for ReviewFixture {
9005        fn drop(&mut self) {
9006            let _ = std::fs::remove_dir_all(&self.root);
9007        }
9008    }
9009
9010    fn test_git(cwd: &Path, args: &[&str]) -> String {
9011        let output = Command::new("git")
9012            .args(args)
9013            .current_dir(cwd)
9014            .output()
9015            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
9016        assert!(
9017            output.status.success(),
9018            "git {args:?} failed: {}",
9019            String::from_utf8_lossy(&output.stderr)
9020        );
9021        String::from_utf8_lossy(&output.stdout).into_owned()
9022    }
9023
9024    /// The diff a line comment is checked against is parsed from git's output,
9025    /// so a user setting that changes that output must not change the answer,
9026    /// and a text converter the branch could have changed must never run.
9027    #[test]
9028    fn the_lines_a_comment_can_go_on_ignore_how_the_user_has_git_print_a_diff() {
9029        let root =
9030            std::env::temp_dir().join(format!("spar-repo-test-hunks-{}", std::process::id()));
9031        let _cleanup = ReviewFixture { root: root.clone() };
9032        let origin = root.join("origin.git");
9033        let work = root.join("work");
9034        let marker = root.join("textconv-ran");
9035        std::fs::create_dir_all(&origin).unwrap();
9036        std::fs::create_dir_all(&work).unwrap();
9037        test_git(&origin, &["init", "--bare", "-b", "main"]);
9038        test_git(&work, &["init", "-b", "main"]);
9039        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9040        test_git(&work, &["config", "user.name", "spar test"]);
9041        test_git(&work, &["config", "commit.gpgsign", "false"]);
9042        test_git(&work, &["config", "diff.noprefix", "true"]);
9043        test_git(&work, &["config", "diff.context", "0"]);
9044        test_git(&work, &["config", "diff.interHunkContext", "10"]);
9045        test_git(&work, &["config", "diff.algorithm", "patience"]);
9046        test_git(&work, &["config", "color.diff", "always"]);
9047        test_git(
9048            &work,
9049            &[
9050                "config",
9051                "diff.conv.textconv",
9052                &format!("touch {}; cat", marker.display()),
9053            ],
9054        );
9055        std::fs::write(work.join(".git/info/attributes"), "*.txt diff=conv\n").unwrap();
9056        let lines: String = (1..=20).map(|n| format!("line {n}\n")).collect();
9057        std::fs::write(work.join("src.txt"), &lines).unwrap();
9058        test_git(&work, &["add", "."]);
9059        test_git(&work, &["commit", "-m", "seed"]);
9060        test_git(
9061            &work,
9062            &["remote", "add", "origin", origin.to_str().unwrap()],
9063        );
9064        test_git(&work, &["push", "-u", "origin", "main"]);
9065        test_git(&work, &["checkout", "-b", "feature"]);
9066        std::fs::write(
9067            work.join("src.txt"),
9068            lines
9069                .replace("line 5\n", "changed 5\n")
9070                .replace("line 15\n", "changed 15\n"),
9071        )
9072        .unwrap();
9073        std::fs::write(work.join("new.txt"), "a\nb\n").unwrap();
9074        std::fs::write(work.join("with space.txt"), "a\n").unwrap();
9075        test_git(&work, &["add", "."]);
9076        test_git(&work, &["commit", "-m", "change"]);
9077        let cfg = crate::config::parse(
9078            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9079        )
9080        .unwrap();
9081        let repo = Repo::open(&work, &cfg).unwrap();
9082        assert_eq!(
9083            Hunks::from([
9084                ("new.txt".to_string(), vec![(1, 2)]),
9085                ("src.txt".to_string(), vec![(2, 8), (12, 18)]),
9086                ("with space.txt".to_string(), vec![(1, 1)]),
9087            ]),
9088            repo.diff_hunks(&work, "main")
9089        );
9090        assert!(!marker.exists(), "the text converter ran");
9091        // The converter is real: a plain diff with the same settings runs it.
9092        test_git(&work, &["diff", "origin/main...HEAD"]);
9093        assert!(marker.exists(), "the fixture's converter never runs at all");
9094    }
9095
9096    fn review_fixture(
9097        tag: &str,
9098        number: i64,
9099    ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
9100        use std::sync::atomic::{AtomicU32, Ordering};
9101        static NEXT: AtomicU32 = AtomicU32::new(0);
9102        let id = NEXT.fetch_add(1, Ordering::Relaxed);
9103        let root =
9104            std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
9105        let origin = root.join("origin.git");
9106        let work = root.join("work");
9107        std::fs::create_dir_all(&origin).unwrap();
9108        std::fs::create_dir_all(&work).unwrap();
9109        test_git(&origin, &["init", "--bare", "-b", "main"]);
9110        test_git(&work, &["init", "-b", "main"]);
9111        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9112        test_git(&work, &["config", "user.name", "spar test"]);
9113        test_git(&work, &["config", "commit.gpgsign", "false"]);
9114        test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
9115        test_git(&work, &["config", "filter.drop.smudge", "cat"]);
9116        std::fs::write(work.join("README.md"), "seed\n").unwrap();
9117        std::fs::write(work.join("data.txt"), "old\n").unwrap();
9118        std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
9119        std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
9120        test_git(&work, &["add", "."]);
9121        test_git(&work, &["commit", "-m", "seed"]);
9122        test_git(
9123            &work,
9124            &["remote", "add", "origin", origin.to_str().unwrap()],
9125        );
9126        test_git(&work, &["push", "-u", "origin", "main"]);
9127        test_git(
9128            &work,
9129            &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
9130        );
9131        let cfg = crate::config::parse(
9132            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9133        )
9134        .unwrap();
9135        let repo = Repo::open(&work, &cfg).unwrap();
9136        let path = repo.worktree_for_pr_head(number).unwrap();
9137        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9138        (ReviewFixture { root }, repo, path, checkpoint)
9139    }
9140
9141    #[test]
9142    fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
9143        let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
9144
9145        repo.release_review_worktree_checked(901, &checkpoint)
9146            .unwrap();
9147
9148        assert!(!path.exists());
9149    }
9150
9151    #[test]
9152    fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
9153        let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
9154        let work = fixture.root.join("work");
9155        let seed = test_git(&work, &["rev-parse", "HEAD"]);
9156        let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
9157        // Heads only the pull request holds, the second rewritten rather than
9158        // built on the first, as a rebase and force push leave them.
9159        let publish = |message: &str| {
9160            let oid = test_git(
9161                &work,
9162                &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
9163            );
9164            let oid = oid.trim().to_string();
9165            test_git(
9166                &work,
9167                &[
9168                    "push",
9169                    "--force",
9170                    "origin",
9171                    &format!("{oid}:refs/pull/923/head"),
9172                ],
9173            );
9174            oid
9175        };
9176        let reviewed = publish("reviewed head");
9177        repo.worktree_for_pr_head(923).unwrap();
9178        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
9179
9180        let rewritten = publish("rewritten head");
9181        repo.worktree_for_pr_head(923).unwrap();
9182
9183        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
9184    }
9185
9186    #[test]
9187    fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
9188        let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
9189        let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
9190        std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
9191        test_git(&path, &["add", "recovery.txt"]);
9192        test_git(&path, &["commit", "-m", "recovery commit"]);
9193        let recovery = test_git(&path, &["rev-parse", "HEAD"]);
9194        test_git(&path, &["reset", "--hard", "main"]);
9195
9196        assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
9197        test_git(
9198            &path,
9199            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9200        );
9201    }
9202
9203    #[test]
9204    fn a_review_ref_reflog_only_commit_prevents_deletion() {
9205        let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
9206        let local_ref = review_ref(921);
9207        let original = test_git(&path, &["rev-parse", &local_ref]);
9208        let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
9209        let recovery = test_git(
9210            &path,
9211            &[
9212                "commit-tree",
9213                tree.trim(),
9214                "-p",
9215                original.trim(),
9216                "-m",
9217                "review ref recovery",
9218            ],
9219        );
9220        test_git(
9221            &path,
9222            &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
9223        );
9224        test_git(
9225            &path,
9226            &["update-ref", &local_ref, original.trim(), recovery.trim()],
9227        );
9228
9229        assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
9230        assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
9231        test_git(
9232            &path,
9233            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9234        );
9235    }
9236
9237    #[test]
9238    fn an_unpublished_commit_message_draft_is_recoverable() {
9239        let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
9240        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9241        let git_dir = if raw.is_absolute() {
9242            raw
9243        } else {
9244            path.join(raw)
9245        };
9246        std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
9247
9248        assert!(repository_has_recoverable_work(&path, true).unwrap());
9249        assert_eq!(
9250            "unique recovery draft\n",
9251            std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
9252        );
9253    }
9254
9255    #[test]
9256    fn a_changed_review_worktree_is_retained_after_a_checked_read() {
9257        let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
9258        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9259
9260        let error = repo
9261            .release_review_worktree_checked(902, &checkpoint)
9262            .unwrap_err();
9263
9264        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9265        assert!(error.to_string().contains("kept for recovery"), "{error}");
9266        assert_eq!(
9267            "recover me\n",
9268            std::fs::read_to_string(path.join("README.md")).unwrap()
9269        );
9270        repo.release_review_worktree(902);
9271    }
9272
9273    #[test]
9274    fn a_review_commit_is_retained_after_a_checked_read() {
9275        let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
9276        std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
9277        test_git(&path, &["add", "review-note.txt"]);
9278        test_git(&path, &["commit", "-m", "local review recovery"]);
9279        let head = test_git(&path, &["rev-parse", "HEAD"]);
9280
9281        let error = repo
9282            .release_review_worktree_checked(903, &checkpoint)
9283            .unwrap_err();
9284
9285        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9286        assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
9287        assert_eq!(
9288            "recover me\n",
9289            std::fs::read_to_string(path.join("review-note.txt")).unwrap()
9290        );
9291        repo.release_review_worktree(903);
9292    }
9293
9294    /// The read is no longer what refuses: a file the call created and Git was
9295    /// told to ignore is its own output. Removal still refuses, so the file is
9296    /// there to look at, which is the part that mattered.
9297    #[test]
9298    fn an_ignored_review_file_is_retained_after_a_checked_read() {
9299        let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
9300        std::fs::create_dir_all(path.join("generated")).unwrap();
9301        std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
9302
9303        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9304            .expect("its own output is not a change");
9305        let error = repo
9306            .release_review_worktree_checked(904, &checkpoint)
9307            .unwrap_err();
9308
9309        assert!(error.to_string().contains("recoverable"), "{error}");
9310        assert_eq!(
9311            "recover me\n",
9312            std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
9313        );
9314        repo.release_review_worktree(904);
9315    }
9316
9317    #[test]
9318    /// The checkpoint no longer refuses this: a path the project ignores is out
9319    /// of scope for a call that only read. What keeps the file is the gate that
9320    /// actually deletes the checkout, which refuses any ignored file outside a
9321    /// known build directory. That is the check with the stake in it, and it is
9322    /// the one that has to hold.
9323    fn a_preexisting_ignored_review_file_change_is_retained() {
9324        let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
9325        std::fs::create_dir_all(path.join("generated")).unwrap();
9326        let ignored = path.join("generated/recovery.txt");
9327        std::fs::write(&ignored, "before\n").unwrap();
9328        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9329        std::fs::write(&ignored, "after!\n").unwrap();
9330
9331        let error = repo
9332            .release_review_worktree_checked(905, &checkpoint)
9333            .unwrap_err();
9334
9335        assert!(error.to_string().contains("recoverable"), "{error}");
9336        assert!(path.exists());
9337        assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
9338        repo.release_review_worktree(905);
9339    }
9340
9341    #[test]
9342    fn a_preexisting_ignored_review_file_prevents_checked_removal() {
9343        let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
9344        std::fs::create_dir_all(path.join("generated")).unwrap();
9345        let ignored = path.join("generated/recovery.txt");
9346        std::fs::write(&ignored, "keep me\n").unwrap();
9347        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9348
9349        let error = repo
9350            .release_review_worktree_checked(906, &checkpoint)
9351            .unwrap_err();
9352
9353        assert!(error.to_string().contains("recoverable"), "{error}");
9354        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
9355    }
9356
9357    #[test]
9358    fn overwriting_a_preexisting_untracked_file_is_detected() {
9359        let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
9360        let untracked = path.join("notes.txt");
9361        std::fs::write(&untracked, "before\n").unwrap();
9362        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9363        std::fs::write(&untracked, "after!\n").unwrap();
9364
9365        let error = repo
9366            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9367            .unwrap_err();
9368
9369        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9370        assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
9371    }
9372
9373    #[test]
9374    fn an_assume_unchanged_edit_is_detected() {
9375        let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
9376        test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
9377        std::fs::write(path.join("README.md"), "hidden\n").unwrap();
9378
9379        let error = repo
9380            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9381            .unwrap_err();
9382
9383        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9384        assert_eq!(
9385            "hidden\n",
9386            std::fs::read_to_string(path.join("README.md")).unwrap()
9387        );
9388    }
9389
9390    #[test]
9391    fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
9392        let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
9393        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9394        test_git(&path, &["add", "README.md"]);
9395        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9396
9397        let error = repo
9398            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9399            .unwrap_err();
9400
9401        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9402        assert_eq!(
9403            b"seed\r\n",
9404            std::fs::read(path.join("README.md")).unwrap().as_slice()
9405        );
9406    }
9407
9408    #[cfg(unix)]
9409    #[test]
9410    fn a_mode_edit_is_detected_when_filemode_is_disabled() {
9411        use std::os::unix::fs::PermissionsExt;
9412
9413        let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
9414        test_git(&path, &["config", "core.filemode", "false"]);
9415        let readme = path.join("README.md");
9416        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9417        permissions.set_mode(0o755);
9418        std::fs::set_permissions(&readme, permissions).unwrap();
9419        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9420
9421        let error = repo
9422            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9423            .unwrap_err();
9424
9425        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9426        assert_eq!(
9427            0o755,
9428            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9429        );
9430    }
9431
9432    #[test]
9433    fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
9434        let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
9435        std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
9436        test_git(&path, &["add", ".gitattributes"]);
9437        test_git(&path, &["commit", "-m", "select data filter"]);
9438        let baseline = repo.worktree_baseline(&path).unwrap();
9439        std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
9440
9441        assert!(repo
9442            .commit_pending_changes(&path, &baseline, "change data", "change data")
9443            .unwrap());
9444        let error = repo
9445            .refuse_unrepresented_tracked_changes(&path, &baseline)
9446            .unwrap_err();
9447
9448        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9449        assert_eq!(
9450            "secret: recover me\nnew\n",
9451            std::fs::read_to_string(path.join("data.txt")).unwrap()
9452        );
9453        assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
9454    }
9455
9456    #[test]
9457    fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
9458        let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
9459        std::fs::create_dir_all(path.join("target")).unwrap();
9460        let untracked = path.join("target/user.yaml");
9461        std::fs::write(&untracked, "user data\n").unwrap();
9462        let baseline = repo.worktree_baseline(&path).unwrap();
9463        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9464
9465        assert!(repo
9466            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9467            .unwrap());
9468
9469        assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
9470        assert_eq!(
9471            "?? target/user.yaml\n",
9472            test_git(&path, &["status", "--short", "--untracked-files=all"])
9473        );
9474        assert!(test_git(
9475            &path,
9476            &[
9477                "ls-tree",
9478                "-r",
9479                "--name-only",
9480                "HEAD",
9481                "--",
9482                "target/user.yaml"
9483            ]
9484        )
9485        .is_empty());
9486    }
9487
9488    #[test]
9489    fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
9490        let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
9491        std::fs::create_dir_all(path.join("target")).unwrap();
9492        let untracked = path.join("target/user.yaml");
9493        std::fs::write(&untracked, "before\n").unwrap();
9494        let baseline = repo.worktree_baseline(&path).unwrap();
9495        let before = test_git(&path, &["rev-parse", "HEAD"]);
9496        std::fs::write(&untracked, "after\n").unwrap();
9497        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9498
9499        let error = repo
9500            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9501            .unwrap_err();
9502
9503        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9504        assert!(error.to_string().contains("target/user.yaml"), "{error}");
9505        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
9506        assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
9507        assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
9508    }
9509
9510    #[test]
9511    fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
9512        let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
9513        let baseline = repo.worktree_baseline(&path).unwrap();
9514        std::fs::create_dir_all(path.join("target")).unwrap();
9515        std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
9516
9517        assert!(repo
9518            .commit_pending_changes(&path, &baseline, "add file", "add file")
9519            .unwrap());
9520
9521        assert_eq!(
9522            "new file\n",
9523            test_git(&path, &["show", "HEAD:target/new.txt"])
9524        );
9525        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9526    }
9527
9528    #[test]
9529    fn a_managed_commit_accepts_a_custom_output_rebuild() {
9530        let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
9531        exclude_paths(&repo, &["manager/public/"]);
9532        let output = path.join("manager/public/assets");
9533        std::fs::create_dir_all(&output).unwrap();
9534        std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
9535        std::fs::write(output.join("index.css"), "old styles\n").unwrap();
9536        let baseline = repo.worktree_baseline(&path).unwrap();
9537        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9538        std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
9539        std::fs::remove_file(output.join("old.js")).unwrap();
9540        std::fs::write(output.join("index.css"), "new styles\n").unwrap();
9541        std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
9542
9543        assert!(repo
9544            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9545            .unwrap());
9546        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9547        repo.refuse_changed_existing_untracked(&path, &baseline)
9548            .unwrap();
9549        repo.refuse_unrepresented_tracked_changes(&path, &baseline)
9550            .unwrap();
9551
9552        assert_eq!(
9553            "tracked change\n",
9554            test_git(&path, &["show", "HEAD:README.md"])
9555        );
9556        assert_eq!(
9557            "new source\n",
9558            test_git(&path, &["show", "HEAD:new-source.txt"])
9559        );
9560        assert!(test_git(
9561            &path,
9562            &[
9563                "ls-tree",
9564                "-r",
9565                "--name-only",
9566                "HEAD",
9567                "--",
9568                "manager/public"
9569            ]
9570        )
9571        .is_empty());
9572        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9573        assert!(!output.join("old.js").exists());
9574        assert_eq!(
9575            "new styles\n",
9576            std::fs::read_to_string(output.join("index.css")).unwrap()
9577        );
9578        assert!(repository_has_recoverable_work(&path, true).unwrap());
9579        repo.release_review_worktree(912);
9580        assert!(output.join("index-DUym-Rfj.js").exists());
9581    }
9582
9583    #[test]
9584    fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
9585        let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
9586        std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
9587        let baseline = repo.worktree_baseline(&path).unwrap();
9588        std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
9589        std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
9590
9591        let error = repo
9592            .commit_pending_changes(&path, &baseline, "change", "change")
9593            .unwrap_err();
9594
9595        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9596        assert!(error.to_string().contains("notes.txt"), "{error}");
9597    }
9598
9599    #[test]
9600    fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
9601        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
9602        let baseline = repo.worktree_baseline(&path).unwrap();
9603        let nested = path.join("generated/repro");
9604        std::fs::create_dir_all(&nested).unwrap();
9605        test_git(&nested, &["init"]);
9606        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9607
9608        assert!(repo
9609            .commit_pending_changes(&path, &baseline, "change", "change")
9610            .unwrap());
9611        assert!(!repo.has_uncommitted_changes(&path).unwrap());
9612        assert!(repository_has_recoverable_work(&path, true).unwrap());
9613        repo.release_review_worktree(927);
9614        assert!(nested.join(".git").exists());
9615    }
9616
9617    #[test]
9618    fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
9619        let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
9620        test_git(&path, &["config", "core.autocrlf", "true"]);
9621        std::fs::write(path.join("README.md"), "seed\n").unwrap();
9622        assert_eq!(
9623            test_git(&path, &["hash-object", "README.md"]).trim(),
9624            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9625        );
9626
9627        assert!(repository_has_recoverable_work(&path, true).unwrap());
9628        assert_eq!(
9629            "seed\n",
9630            std::fs::read_to_string(path.join("README.md")).unwrap()
9631        );
9632    }
9633
9634    #[test]
9635    fn autocrlf_input_overrides_a_crlf_core_eol() {
9636        let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
9637        test_git(&path, &["config", "core.autocrlf", "input"]);
9638        test_git(&path, &["config", "core.eol", "crlf"]);
9639        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9640        assert_eq!(
9641            test_git(&path, &["hash-object", "README.md"]).trim(),
9642            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9643        );
9644
9645        assert!(repository_has_recoverable_work(&path, true).unwrap());
9646        assert_eq!(
9647            b"seed\r\n",
9648            std::fs::read(path.join("README.md")).unwrap().as_slice()
9649        );
9650    }
9651
9652    #[cfg(unix)]
9653    #[test]
9654    fn a_non_executable_permission_change_is_recoverable() {
9655        use std::os::unix::fs::PermissionsExt;
9656
9657        let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
9658        let readme = path.join("README.md");
9659        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9660        permissions.set_mode(0o600);
9661        std::fs::set_permissions(&readme, permissions).unwrap();
9662        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9663
9664        let error = repo
9665            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9666            .unwrap_err();
9667
9668        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9669        assert!(repository_has_recoverable_work(&path, true).unwrap());
9670        assert_eq!(
9671            0o600,
9672            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9673        );
9674    }
9675
9676    #[cfg(unix)]
9677    #[test]
9678    fn a_managed_commit_skips_signing_and_hooks() {
9679        use std::os::unix::fs::PermissionsExt;
9680
9681        let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
9682        let common = common_git_dir(&path).unwrap();
9683        let hook = common.join("hooks/pre-commit");
9684        let marker = fixture.root.join("hook-ran");
9685        std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
9686        std::fs::write(
9687            &hook,
9688            format!(
9689                "#!/bin/sh\nprintf ran > {}\nexit 1\n",
9690                sh_quote(marker.to_str().unwrap())
9691            ),
9692        )
9693        .unwrap();
9694        let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
9695        permissions.set_mode(0o755);
9696        std::fs::set_permissions(&hook, permissions).unwrap();
9697        test_git(&path, &["config", "commit.gpgsign", "true"]);
9698        test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
9699        std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
9700        test_git(&path, &["add", "managed.txt"]);
9701
9702        repo.commit_staged_changes(&path, "record managed change")
9703            .unwrap();
9704
9705        assert!(!marker.exists());
9706        assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
9707    }
9708
9709    #[test]
9710    fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
9711        let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
9712        std::fs::write(
9713            path.join(".gitattributes"),
9714            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
9715        )
9716        .unwrap();
9717        test_git(&path, &["add", ".gitattributes"]);
9718        test_git(&path, &["commit", "-m", "select automatic text"]);
9719        test_git(&path, &["config", "core.autocrlf", "true"]);
9720
9721        assert!(repository_has_recoverable_work(&path, true).unwrap());
9722    }
9723
9724    #[test]
9725    fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
9726        let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
9727        std::fs::write(
9728            path.join(".gitattributes"),
9729            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
9730        )
9731        .unwrap();
9732        test_git(&path, &["add", ".gitattributes"]);
9733        test_git(&path, &["commit", "-m", "select ident expansion"]);
9734        std::fs::write(path.join("README.md"), "seed\n").unwrap();
9735
9736        assert!(repository_has_recoverable_work(&path, true).unwrap());
9737    }
9738
9739    /// Add ignore rules the way SPAR does, without a tracked change the
9740    /// worktree would then be kept for.
9741    /// The failure this whole rule was rewritten for: a reviewer ran the
9742    /// project's binding generator to check a finding, and the lockfile it left
9743    /// beside its own manifest matched no directory name spar knew.
9744    #[test]
9745    fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
9746        let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
9747        exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
9748        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9749        std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
9750        std::fs::write(
9751            path.join("bindings/uniffi-bindgen/Cargo.lock"),
9752            "# generated\n",
9753        )
9754        .unwrap();
9755
9756        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9757            .expect("a path the project ignores is the call's own output");
9758    }
9759
9760    #[test]
9761    fn an_attribute_file_under_build_output_is_not_a_filter_change() {
9762        let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
9763        exclude_paths(&repo, &["target/"]);
9764        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9765        std::fs::create_dir_all(path.join("target/package")).unwrap();
9766        std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
9767
9768        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9769            .expect("an ignored attribute file governs only ignored paths");
9770        assert!(
9771            !repo.has_uncommitted_changes(&path).unwrap(),
9772            "ignored dependency attributes must not stop the next review"
9773        );
9774    }
9775
9776    #[test]
9777    fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
9778        let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
9779        std::fs::create_dir_all(path.join("source")).unwrap();
9780        std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
9781
9782        assert!(repo.has_uncommitted_changes(&path).unwrap());
9783    }
9784
9785    #[test]
9786    fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
9787        for source in ["repository", "nested", "local", "global"] {
9788            let (_fixture, repo, path, _initial) = review_fixture(source, 945);
9789            std::fs::create_dir_all(path.join("manager")).unwrap();
9790            match source {
9791                "repository" => {
9792                    std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
9793                        .unwrap();
9794                    test_git(&path, &["add", ".gitignore"]);
9795                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
9796                }
9797                "nested" => {
9798                    std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
9799                    test_git(&path, &["add", "manager/.gitignore"]);
9800                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
9801                }
9802                "local" => exclude_paths(&repo, &["manager/scratch/"]),
9803                "global" => {
9804                    let excludes = repo.root().parent().unwrap().join("global-ignore");
9805                    std::fs::write(&excludes, "manager/scratch/\n").unwrap();
9806                    test_git(
9807                        &path,
9808                        &["config", "core.excludesFile", excludes.to_str().unwrap()],
9809                    );
9810                }
9811                _ => unreachable!(),
9812            }
9813            let output = path.join("manager/scratch");
9814            std::fs::create_dir_all(&output).unwrap();
9815            std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
9816            std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
9817            let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9818            std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
9819            std::fs::remove_file(output.join(".DS_Store")).unwrap();
9820            for file in [
9821                ".gitattributes",
9822                "Cargo.lock",
9823                "font.woff2",
9824                "bundle.js",
9825                "cache.db",
9826            ] {
9827                std::fs::write(output.join(file), "generated\n").unwrap();
9828            }
9829            #[cfg(unix)]
9830            std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
9831            let nested = output.join("dependency");
9832            std::fs::create_dir_all(&nested).unwrap();
9833            test_git(&nested, &["init"]);
9834            repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9835                .unwrap_or_else(|error| panic!("{source}: {error}"));
9836            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9837
9838            let baseline = repo.worktree_baseline(&path).unwrap();
9839            std::fs::write(path.join("README.md"), "source fix\n").unwrap();
9840            std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
9841            std::fs::remove_file(output.join("Cargo.lock")).unwrap();
9842            std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
9843            std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
9844            assert!(repo
9845                .commit_pending_changes(&path, &baseline, "fix source", "fix source")
9846                .unwrap_or_else(|error| panic!("{source}: {error}")));
9847            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9848            assert!(test_git(
9849                &path,
9850                &[
9851                    "ls-tree",
9852                    "-r",
9853                    "--name-only",
9854                    "HEAD",
9855                    "--",
9856                    "manager/scratch"
9857                ]
9858            )
9859            .is_empty());
9860            assert!(
9861                repository_has_recoverable_work(&path, true).unwrap(),
9862                "{source}"
9863            );
9864            repo.release_review_worktree(945);
9865            assert_eq!(
9866                "LOCAL=updated\n",
9867                std::fs::read_to_string(output.join(".env.local")).unwrap()
9868            );
9869            assert!(nested.join(".git").exists(), "{source}");
9870        }
9871    }
9872
9873    /// Two reviewers share one checkout. The marker written when the first
9874    /// one's answer was refused used to be a brand new untracked file in the
9875    /// snapshot the second one was compared against.
9876    #[test]
9877    fn a_recovery_marker_is_not_a_change_to_the_worktree() {
9878        let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
9879        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9880        let _ = uncertain_worktree_change(&path, "an earlier call was refused");
9881
9882        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9883            .expect("spar's own marker is not something a call did");
9884    }
9885
9886    #[test]
9887    fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
9888        let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
9889
9890        let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
9891            .expect("a path a build removed is not an unreadable worktree");
9892
9893        assert_eq!(vanished_untracked_file(), gone);
9894    }
9895
9896    /// A path that goes is still a path that was there. Dropping it from the
9897    /// snapshot would let it pass for one the call never touched.
9898    #[test]
9899    fn a_vanished_path_is_not_recorded_as_absent() {
9900        let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
9901        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
9902        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9903        std::fs::remove_file(path.join("notes.txt")).unwrap();
9904
9905        let error = repo
9906            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9907            .unwrap_err();
9908
9909        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9910    }
9911
9912    fn exclude_paths(repo: &Repo, lines: &[&str]) {
9913        use std::io::Write;
9914        let path = repo.root().join(".git").join("info").join("exclude");
9915        let mut file = std::fs::OpenOptions::new()
9916            .create(true)
9917            .append(true)
9918            .open(&path)
9919            .unwrap();
9920        for line in lines {
9921            writeln!(file, "{line}").unwrap();
9922        }
9923    }
9924
9925    #[test]
9926    fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
9927        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
9928        exclude_paths(&repo, &["dist/"]);
9929        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9930        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9931        let baseline = repo.worktree_baseline(&path).unwrap();
9932        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9933        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9934        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9935        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9936
9937        assert!(repo
9938            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9939            .unwrap());
9940        assert_eq!(
9941            "tracked change\n",
9942            std::fs::read_to_string(path.join("README.md")).unwrap()
9943        );
9944    }
9945
9946    #[test]
9947    fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
9948        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
9949        exclude_paths(&repo, &["dist/"]);
9950        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9951        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9952        std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
9953        let baseline = repo.worktree_baseline(&path).unwrap();
9954        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9955        std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
9956        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9957        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9958
9959        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9960        repo.refuse_changed_existing_untracked(&path, &baseline)
9961            .unwrap();
9962    }
9963
9964    #[test]
9965    fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
9966        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
9967        exclude_paths(&repo, &["dist/"]);
9968        std::fs::create_dir_all(path.join("dist")).unwrap();
9969        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
9970        let baseline = repo.worktree_baseline(&path).unwrap();
9971        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
9972        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
9973
9974        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9975        repo.refuse_changed_existing_untracked(&path, &baseline)
9976            .unwrap();
9977    }
9978
9979    /// The run this comes from: a reviewer rebuilt a project whose build output
9980    /// lands in `manager/public/assets/`, which no directory list will ever
9981    /// name. The guard inside the call kept the review. This check then called
9982    /// the same thirteen files unrepresentable and ended the round, five
9983    /// minutes of review thrown away over a path Git had been told to disown.
9984    /// The run this comes from: `gh pr merge --delete-branch` could not delete
9985    /// `issue-784` because `.spar-worktrees/issue-784`, left by the run that
9986    /// opened the pull request, still had it checked out.
9987    #[test]
9988    fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
9989        let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
9990        let held = repo.root().join(WORKTREE_DIR).join("issue-950");
9991        std::fs::create_dir_all(held.parent().unwrap()).unwrap();
9992        test_git(
9993            repo.root(),
9994            &[
9995                "worktree",
9996                "add",
9997                "-b",
9998                "issue-950",
9999                held.to_str().unwrap(),
10000                "main",
10001            ],
10002        );
10003
10004        let found = repo
10005            .worktree_holding("issue-950")
10006            .expect("the branch is checked out somewhere");
10007
10008        assert!(found.ends_with("issue-950"), "{}", found.display());
10009        assert_eq!(None, repo.worktree_holding("issue-951"));
10010    }
10011
10012    #[test]
10013    fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
10014        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
10015        exclude_paths(&repo, &["manager/public/assets/"]);
10016        std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
10017        let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
10018        std::fs::write(&bundle, "first build\n").unwrap();
10019        let baseline = repo.worktree_baseline(&path).unwrap();
10020        std::fs::write(&bundle, "second build\n").unwrap();
10021        std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
10022
10023        repo.refuse_read_only_leavings(&path, &baseline).unwrap();
10024        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10025    }
10026
10027    #[test]
10028    fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
10029        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
10030        exclude_paths(&repo, &["dist/"]);
10031        let baseline = repo.worktree_baseline(&path).unwrap();
10032        std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
10033
10034        let error = repo
10035            .refuse_read_only_leavings(&path, &baseline)
10036            .unwrap_err();
10037
10038        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10039        assert!(error.to_string().contains("scratch.md"), "{error}");
10040    }
10041
10042    #[test]
10043    fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
10044        let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
10045        exclude_paths(&repo, &["dist/", ".env.local"]);
10046        std::fs::create_dir_all(path.join("dist")).unwrap();
10047        let baseline = repo.worktree_baseline(&path).unwrap();
10048        std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
10049        std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
10050
10051        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10052        assert!(repository_has_recoverable_work(&path, true).unwrap());
10053    }
10054
10055    #[test]
10056    fn a_read_only_inspection_may_rebuild_generated_output() {
10057        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
10058        exclude_paths(&repo, &["dist/"]);
10059        std::fs::create_dir_all(path.join("dist")).unwrap();
10060        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10061        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10062        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10063        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10064
10065        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10066            .unwrap();
10067    }
10068
10069    #[test]
10070    fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
10071        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
10072        exclude_paths(&repo, &["dist/", ".env.local"]);
10073        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10074        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10075        std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
10076
10077        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10078            .unwrap();
10079    }
10080
10081    #[test]
10082    fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
10083        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
10084        exclude_paths(&repo, &["dist/", ".env.local"]);
10085        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10086        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10087        std::fs::remove_file(path.join(".env.local")).unwrap();
10088
10089        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10090            .unwrap();
10091    }
10092
10093    #[test]
10094    fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
10095        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
10096        exclude_paths(&repo, &["dist/"]);
10097        std::fs::write(path.join("notes.md"), "before\n").unwrap();
10098        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10099        std::fs::write(path.join("notes.md"), "after\n").unwrap();
10100
10101        let error = repo
10102            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10103            .unwrap_err();
10104
10105        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10106        assert!(error.to_string().contains("notes.md"), "{error}");
10107    }
10108
10109    #[test]
10110    fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
10111        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
10112        exclude_paths(&repo, &["dist/", "local.env"]);
10113        std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
10114        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10115        // Stop ignoring it, so the same bytes are now an ordinary untracked
10116        // file. What a path is counts, not only whether it moved.
10117        std::fs::write(
10118            repo.root().join(".git").join("info").join("exclude"),
10119            "dist/\n",
10120        )
10121        .unwrap();
10122
10123        let error = repo
10124            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10125            .unwrap_err();
10126
10127        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10128        assert!(error.to_string().contains("local.env"), "{error}");
10129    }
10130
10131    #[test]
10132    fn a_read_only_inspection_names_the_tracked_file_that_moved() {
10133        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
10134        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10135        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
10136
10137        let error = repo
10138            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10139            .unwrap_err();
10140
10141        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10142        assert!(error.to_string().contains("README.md"), "{error}");
10143    }
10144
10145    #[test]
10146    fn build_output_alone_does_not_keep_a_worktree() {
10147        let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
10148        exclude_paths(&repo, &["target/", "dist/"]);
10149        std::fs::create_dir_all(path.join("target/debug")).unwrap();
10150        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10151        std::fs::create_dir_all(path.join("dist/cli")).unwrap();
10152        std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
10153
10154        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10155        repo.release_review_worktree(933);
10156
10157        assert!(!path.exists());
10158    }
10159
10160    #[test]
10161    fn finder_metadata_alone_does_not_keep_a_worktree() {
10162        let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
10163        exclude_paths(&repo, &[".DS_Store"]);
10164        std::fs::create_dir_all(path.join("src")).unwrap();
10165        std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
10166        std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
10167
10168        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10169        repo.release_review_worktree(937);
10170
10171        assert!(!path.exists());
10172    }
10173
10174    #[test]
10175    fn a_fetch_record_alone_does_not_keep_a_worktree() {
10176        let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
10177        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
10178        let git_dir = if raw.is_absolute() {
10179            raw
10180        } else {
10181            path.join(raw)
10182        };
10183        let head = test_git(&path, &["rev-parse", "HEAD"]);
10184        std::fs::write(
10185            git_dir.join("FETCH_HEAD"),
10186            format!("{}\t\tbranch 'main' of origin\n", head.trim()),
10187        )
10188        .unwrap();
10189
10190        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10191        repo.release_review_worktree(938);
10192
10193        assert!(!path.exists());
10194    }
10195
10196    #[test]
10197    fn an_ignored_file_outside_build_output_keeps_a_worktree() {
10198        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
10199        exclude_paths(&repo, &["target/", ".env.local"]);
10200        std::fs::create_dir_all(path.join("target/debug")).unwrap();
10201        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10202        std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
10203
10204        assert!(repository_has_recoverable_work(&path, true).unwrap());
10205        repo.release_review_worktree(934);
10206
10207        assert_eq!(
10208            "TOKEN=keep me\n",
10209            std::fs::read_to_string(path.join(".env.local")).unwrap()
10210        );
10211    }
10212
10213    #[test]
10214    fn a_repository_nested_in_build_output_keeps_a_worktree() {
10215        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
10216        exclude_paths(&repo, &["node_modules/"]);
10217        let nested = path.join("node_modules/local-dep");
10218        std::fs::create_dir_all(&nested).unwrap();
10219        test_git(&nested, &["init"]);
10220        std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
10221
10222        assert!(repository_has_recoverable_work(&path, true).unwrap());
10223        repo.release_review_worktree(935);
10224
10225        assert!(nested.join(".git").exists());
10226    }
10227
10228    #[test]
10229    fn an_ordinary_untracked_file_keeps_a_worktree() {
10230        let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
10231        std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
10232
10233        assert!(repository_has_recoverable_work(&path, true).unwrap());
10234        repo.release_review_worktree(936);
10235
10236        assert_eq!(
10237            "somebody's notes\n",
10238            std::fs::read_to_string(path.join("notes.md")).unwrap()
10239        );
10240    }
10241
10242    #[test]
10243    fn a_legacy_crlf_checkout_is_retained_conservatively() {
10244        let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
10245        std::fs::write(
10246            path.join(".gitattributes"),
10247            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
10248        )
10249        .unwrap();
10250        test_git(&path, &["add", ".gitattributes"]);
10251        test_git(&path, &["commit", "-m", "select legacy line endings"]);
10252
10253        assert!(repository_has_recoverable_work(&path, true).unwrap());
10254    }
10255
10256    #[test]
10257    fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
10258        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
10259        let nested = path.join("tracked");
10260        std::fs::create_dir_all(&nested).unwrap();
10261        std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
10262        test_git(&path, &["add", "tracked/seed.txt"]);
10263        test_git(&path, &["commit", "-m", "add tracked directory"]);
10264        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10265        test_git(&nested, &["init"]);
10266
10267        let error = repo
10268            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10269            .unwrap_err();
10270
10271        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10272        assert!(error.to_string().contains("Git entry"), "{error}");
10273        assert!(nested.join(".git").exists());
10274    }
10275
10276    /// A reviewer that clones a reproduction into ignored build output used to
10277    /// lose the whole review. The state check refused every Git entry it had
10278    /// not put there, including the one Git reports as a nested checkout and
10279    /// the untracked snapshot already records by identity.
10280    #[test]
10281    fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
10282        let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
10283        exclude_paths(&repo, &["target/"]);
10284        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10285        let repro = path.join("target/pr918-repro");
10286        std::fs::create_dir_all(&repro).unwrap();
10287        test_git(&repro, &["init"]);
10288        std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
10289
10290        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10291            .unwrap();
10292
10293        assert!(repository_has_recoverable_work(&path, true).unwrap());
10294        assert!(repro.join(".git").exists());
10295    }
10296
10297    /// What sits under a nested checkout is that repository's business, so the
10298    /// scan stops at its directory rather than reading a checkout of its own
10299    /// as an entry the outer worktree has to account for.
10300    #[test]
10301    fn a_repository_inside_a_nested_checkout_is_left_alone() {
10302        let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
10303        exclude_paths(&repo, &["target/"]);
10304        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10305        let repro = path.join("target/repro");
10306        let vendored = repro.join("vendor/dep");
10307        std::fs::create_dir_all(&vendored).unwrap();
10308        test_git(&repro, &["init"]);
10309        test_git(&vendored, &["init"]);
10310
10311        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10312            .unwrap();
10313
10314        assert!(vendored.join(".git").exists());
10315    }
10316
10317    #[test]
10318    fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
10319        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
10320
10321        let state = ignored_untracked_state(repo.root()).unwrap();
10322
10323        let relative = path.strip_prefix(repo.root()).unwrap();
10324        assert!(
10325            state.files.contains_key(relative),
10326            "{:?}",
10327            state.files.keys().collect::<Vec<_>>()
10328        );
10329        assert!(state.is_ignored(relative));
10330    }
10331
10332    #[test]
10333    fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
10334        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
10335        let baseline = repo.worktree_baseline(repo.root()).unwrap();
10336        std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
10337        std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
10338
10339        repo.refuse_new_ignored_files(repo.root(), &baseline)
10340            .unwrap();
10341        repo.refuse_changed_existing_untracked(repo.root(), &baseline)
10342            .unwrap();
10343    }
10344
10345    #[test]
10346    fn deleting_a_resident_worktree_during_a_call_is_refused() {
10347        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
10348        let baseline = repo.worktree_baseline(repo.root()).unwrap();
10349        let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
10350        std::fs::remove_dir_all(&path).unwrap();
10351
10352        let read_error = repo
10353            .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
10354            .unwrap_err();
10355        assert!(
10356            read_error.to_string().contains("review-932"),
10357            "{read_error}"
10358        );
10359        let error = repo
10360            .refuse_new_ignored_files(repo.root(), &baseline)
10361            .unwrap_err();
10362
10363        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10364        assert!(error.to_string().contains("review-932"), "{error}");
10365    }
10366
10367    #[test]
10368    fn a_nested_repository_record_is_read_as_a_plain_path() {
10369        let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
10370        assert_eq!(Path::new("vendor/checkout"), path);
10371        assert!(nested);
10372
10373        let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
10374        assert_eq!(Path::new("vendor/notes.txt"), path);
10375        assert!(!nested);
10376
10377        assert!(untracked_record(b"/", "untracked").is_err());
10378    }
10379
10380    #[cfg(unix)]
10381    #[test]
10382    fn a_non_utf8_git_path_is_preserved_without_loss() {
10383        use std::os::unix::ffi::OsStrExt;
10384
10385        let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
10386
10387        assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
10388    }
10389
10390    #[test]
10391    fn guarded_merge_pins_the_reviewed_head() {
10392        let args = merge_pr_args("36", Some("abc123"), true);
10393        assert_eq!(
10394            vec![
10395                "pr",
10396                "merge",
10397                "36",
10398                "--squash",
10399                "--delete-branch",
10400                "--match-head-commit",
10401                "abc123"
10402            ],
10403            args
10404        );
10405    }
10406
10407    #[test]
10408    fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
10409        let pr = PrRef {
10410            number: 7,
10411            url: "https://example.test/pull/7".into(),
10412            title: "part one".into(),
10413        };
10414        let result = reconcile_pr_creation(
10415            "split-34-1",
10416            Err(crate::error::SparError::new("connection lost")),
10417            Ok(Some(pr)),
10418        )
10419        .unwrap();
10420        assert_eq!(7, result.number);
10421    }
10422
10423    #[test]
10424    fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
10425        let error = reconcile_pr_creation(
10426            "split-34-1",
10427            Err(crate::error::SparError::new("permission denied")),
10428            Ok(None),
10429        )
10430        .unwrap_err();
10431        assert!(error.to_string().contains("permission denied"), "{error}");
10432    }
10433
10434    #[test]
10435    fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
10436        let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
10437        assert!(pr_for_base(text, "split-34-2", "split-34-1")
10438            .unwrap()
10439            .is_none());
10440        let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
10441        assert_eq!(7, found.number);
10442    }
10443
10444    #[test]
10445    fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
10446        let result = reconcile_comment_post(
10447            34,
10448            "the summary",
10449            crate::error::SparError::new("connection lost"),
10450            Ok(vec![serde_json::json!({"body": "the summary"})]),
10451        );
10452        assert!(result.is_ok(), "{result:?}");
10453    }
10454
10455    #[test]
10456    fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
10457        let error = reconcile_comment_post(
10458            34,
10459            "the summary",
10460            crate::error::SparError::new("connection lost"),
10461            Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
10462        )
10463        .unwrap_err();
10464        assert_eq!("connection lost", error.to_string());
10465    }
10466
10467    #[test]
10468    fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
10469        let error = reconcile_comment_post(
10470            34,
10471            "the summary",
10472            crate::error::SparError::new("connection lost"),
10473            Err(crate::error::SparError::new("comments unavailable")),
10474        )
10475        .unwrap_err();
10476        assert!(
10477            error.to_string().contains("could not be verified"),
10478            "{error}"
10479        );
10480        assert!(
10481            error.to_string().contains("comments unavailable"),
10482            "{error}"
10483        );
10484        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10485        assert!(!error.worth_retrying());
10486    }
10487
10488    #[test]
10489    fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
10490        let result = reconcile_issue_edit(
10491            34,
10492            "wanted body",
10493            crate::error::SparError::new("connection lost"),
10494            Ok("wanted body".to_string()),
10495        );
10496        assert!(result.is_ok(), "{result:?}");
10497    }
10498
10499    #[test]
10500    fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
10501        let error = reconcile_issue_edit(
10502            34,
10503            "wanted body",
10504            crate::error::SparError::new("connection lost"),
10505            Err(crate::error::SparError::new("issue unavailable")),
10506        )
10507        .unwrap_err();
10508        assert!(
10509            error.to_string().contains("could not be verified"),
10510            "{error}"
10511        );
10512        assert!(error.to_string().contains("issue unavailable"), "{error}");
10513        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10514        assert!(!error.worth_retrying());
10515    }
10516
10517    #[test]
10518    fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
10519        let found = ExistingIssue {
10520            number: 101,
10521            url: "https://example.test/issues/101".into(),
10522            title: "child".into(),
10523            body: "body".into(),
10524            open: true,
10525        };
10526        let url = reconcile_issue_creation(
10527            "child",
10528            Err(crate::error::SparError::new("connection lost")),
10529            Ok(Some(found)),
10530        )
10531        .unwrap();
10532        assert_eq!("https://example.test/issues/101", url);
10533    }
10534
10535    #[test]
10536    fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
10537        let error = reconcile_issue_creation(
10538            "child",
10539            Err(crate::error::SparError::new("permission denied")),
10540            Ok(None),
10541        )
10542        .unwrap_err();
10543        assert!(error.to_string().contains("permission denied"), "{error}");
10544    }
10545
10546    #[test]
10547    fn an_unverifiable_issue_creation_is_marked_uncertain() {
10548        let error = reconcile_issue_creation(
10549            "child",
10550            Err(crate::error::SparError::new("connection lost")),
10551            Err(crate::error::SparError::new("issues unavailable")),
10552        )
10553        .unwrap_err();
10554        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10555        assert!(!error.worth_retrying());
10556    }
10557
10558    #[test]
10559    fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
10560        let result = reconcile_failed_split_push(
10561            "split-34-1",
10562            crate::error::SparError::new("connection lost"),
10563            Ok("abc123\n".into()),
10564            Ok("abc123\trefs/heads/split-34-1\n".into()),
10565        );
10566        assert!(result.is_ok(), "{result:?}");
10567    }
10568
10569    #[test]
10570    fn a_split_push_collision_is_definite_and_never_overwrites() {
10571        let error = reconcile_failed_split_push(
10572            "split-34-1",
10573            crate::error::SparError::new("lease rejected"),
10574            Ok("abc123\n".into()),
10575            Ok("def456\trefs/heads/split-34-1\n".into()),
10576        )
10577        .unwrap_err();
10578        assert!(!error.retain_worktree());
10579        assert!(
10580            error.to_string().contains("Nothing was overwritten"),
10581            "{error}"
10582        );
10583    }
10584
10585    #[test]
10586    fn an_unreadable_split_push_result_keeps_the_worktree() {
10587        let error = reconcile_failed_split_push(
10588            "split-34-1",
10589            crate::error::SparError::new("connection lost"),
10590            Ok("abc123\n".into()),
10591            Err(crate::error::SparError::new("origin unavailable")),
10592        )
10593        .unwrap_err();
10594        assert!(error.retain_worktree());
10595        assert!(error.to_string().contains("could not confirm"), "{error}");
10596    }
10597
10598    /// Follow-up deduplication compares a title it computed against the title
10599    /// GitHub stored. If those two transforms can disagree, the check never
10600    /// matches and every review round files another copy of the same issue.
10601    #[test]
10602    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
10603        let repo = repo_for_titles();
10604        for raw in [
10605            "Retry loop spins \u{2014} Retry-After parses to zero",
10606            "plain title",
10607            "  spread   over\nlines  ",
10608            "\u{1F916} Generated with something",
10609            &format!("a \u{2014} {}", "very long title ".repeat(20)),
10610            &"x".repeat(300),
10611            &format!("{} \u{2014} end", "y".repeat(88)),
10612            // Exactly the budget, with two spaceless dashes. The scrub turns
10613            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
10614            // character over budget per dash and a second pass clips again,
10615            // producing a different string. Scrub-then-clip cannot.
10616            &{
10617                let tail = "a\u{2014}b c\u{2014}d";
10618                let pad = Style::default().max_title_chars - tail.chars().count();
10619                format!("{}{tail}", "w".repeat(pad))
10620            },
10621        ] {
10622            let once = repo.clean_title(raw).unwrap();
10623            let twice = repo.clean_title(&once).unwrap();
10624            assert_eq!(once, twice, "not idempotent for {raw:?}");
10625            assert!(
10626                once.chars().count() <= repo.style.max_title_chars,
10627                "over budget: {once:?}"
10628            );
10629            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
10630        }
10631    }
10632
10633    #[test]
10634    fn a_title_with_an_em_dash_survives_as_readable_text() {
10635        let repo = repo_for_titles();
10636        assert_eq!(
10637            "Retry loop spins, Retry-After parses to zero",
10638            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
10639                .unwrap()
10640        );
10641    }
10642
10643    #[test]
10644    fn sh_quote_survives_a_quote() {
10645        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
10646    }
10647
10648    #[test]
10649    fn sh_quote_wraps_a_space() {
10650        assert_eq!(
10651            "'/Applications/My App/spar'",
10652            sh_quote("/Applications/My App/spar")
10653        );
10654    }
10655
10656    #[test]
10657    fn finished_states_are_recognised_case_insensitively() {
10658        assert!(is_finished("MERGED"));
10659        assert!(is_finished("closed"));
10660        assert!(!is_finished("OPEN"));
10661        assert!(!is_finished(""));
10662    }
10663
10664    fn state() -> PersistedState {
10665        PersistedState {
10666            version: 1,
10667            checkpoint: 0,
10668            round: 4,
10669            next_actor: "codex".into(),
10670            status: Status::Pending,
10671            pr_head: "abc123".into(),
10672            ledger: Ledger::new(),
10673            filed: vec![],
10674            open_findings: vec![Finding {
10675                severity: Severity::Blocking,
10676                title: "Unchecked error".into(),
10677                detail: "the failure is discarded".into(),
10678                file: "src/a.rs:12".into(),
10679                ..Finding::default()
10680            }],
10681            disputes: vec![Dispute {
10682                title: "Retry limit".into(),
10683                file: "src/net.rs".into(),
10684                reasoning: "the caller already bounds it".into(),
10685            }],
10686            noted: vec![Finding {
10687                severity: Severity::NonBlocking,
10688                title: "Timeout is fixed".into(),
10689                file: "src/config.rs".into(),
10690                ..Finding::default()
10691            }],
10692        }
10693    }
10694
10695    #[test]
10696    fn a_state_comment_round_trips() {
10697        let body = format!(
10698            "{STATE_MARKER}\n{}\n-->",
10699            serde_json::to_string(&state()).unwrap()
10700        );
10701        let back = parse_state_comment(&body).unwrap();
10702        assert_eq!(4, back.round);
10703        assert_eq!("codex", back.next_actor);
10704        assert_eq!("abc123", back.pr_head);
10705        assert_eq!("Unchecked error", back.open_findings[0].title);
10706        assert_eq!("src/net.rs", back.disputes[0].file);
10707        assert_eq!("Timeout is fixed", back.noted[0].title);
10708    }
10709
10710    #[test]
10711    fn old_state_without_new_lists_still_parses() {
10712        let body = format!(
10713            "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
10714             \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
10715        );
10716        let back = parse_state_comment(&body).expect("old state");
10717        assert!(back.open_findings.is_empty());
10718        assert!(back.disputes.is_empty());
10719        assert!(back.noted.is_empty());
10720        assert!(back.pr_head.is_empty());
10721        assert_eq!(0, back.checkpoint);
10722    }
10723
10724    #[test]
10725    fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
10726        let mut local = state();
10727        local.pr_head = "old".into();
10728        local.round = 9;
10729        let mut remote = state();
10730        remote.pr_head = "current".into();
10731        remote.round = 4;
10732
10733        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10734        assert_eq!("current", chosen.pr_head);
10735        assert_eq!(4, chosen.round);
10736    }
10737
10738    #[test]
10739    fn checkpoint_order_breaks_same_round_ties() {
10740        let mut local = state();
10741        local.pr_head = "current".into();
10742        local.round = 4;
10743        local.checkpoint = 8;
10744        let mut remote = local.clone();
10745        remote.checkpoint = 7;
10746        remote.open_findings.clear();
10747
10748        let chosen = choose_state_for_head(vec![local], "current").unwrap();
10749        assert_eq!(8, chosen.checkpoint);
10750
10751        let mut local = state();
10752        local.pr_head = "current".into();
10753        local.round = 4;
10754        local.checkpoint = 8;
10755        let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
10756        assert_eq!(8, chosen.checkpoint);
10757    }
10758
10759    #[test]
10760    fn legacy_same_round_tie_keeps_the_local_checkpoint() {
10761        let mut local = state();
10762        local.pr_head = "current".into();
10763        local.round = 4;
10764        local.open_findings.push(Finding {
10765            title: "local checkpoint".into(),
10766            ..Finding::default()
10767        });
10768        let mut remote = state();
10769        remote.pr_head = "current".into();
10770        remote.round = 4;
10771
10772        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10773        assert_eq!(
10774            "local checkpoint",
10775            chosen.open_findings.last().unwrap().title
10776        );
10777    }
10778
10779    /// It must render as nothing, so PRs are not littered with machine state.
10780    #[test]
10781    fn the_state_block_is_an_html_comment() {
10782        let body = format!(
10783            "{STATE_MARKER}\n{}\n-->",
10784            serde_json::to_string(&state()).unwrap()
10785        );
10786        assert!(body.starts_with("<!--"));
10787        assert!(body.trim_end().ends_with("-->"));
10788        assert!(!body[..body.find('{').unwrap()].contains("-->"));
10789    }
10790
10791    #[test]
10792    fn an_unrelated_json_block_is_not_state() {
10793        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
10794    }
10795
10796    /// Patching and deleting a comment is not something to do on a substring.
10797    ///
10798    /// A maintainer who notices the block and asks about it writes a comment
10799    /// containing `spar:state`, and the loose filter could not tell theirs from
10800    /// spar's. The next `persist` replaced their words with JSON and
10801    /// `spar clean --pr-state` later deleted it.
10802    #[test]
10803    fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
10804        let block = format!(
10805            "{STATE_MARKER}\n{}\n-->",
10806            serde_json::to_string(&state()).unwrap()
10807        );
10808        assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
10809        assert!(
10810            is_state_comment(&block, "Spar-Bot", "spar-bot"),
10811            "a login differs only in case"
10812        );
10813
10814        assert!(
10815            !is_state_comment(
10816                "why is there a `<!-- spar:state` block on this PR?",
10817                "maintainer",
10818                "spar-bot"
10819            ),
10820            "a question about the block was treated as the block"
10821        );
10822        assert!(
10823            !is_state_comment(&block, "maintainer", "spar-bot"),
10824            "somebody else's comment is not spar's to rewrite"
10825        );
10826        assert!(
10827            !is_state_comment(
10828                &format!("here is what spar writes:\n\n{block}"),
10829                "spar-bot",
10830                "spar-bot"
10831            ),
10832            "the marker has to open the body, not appear in it"
10833        );
10834        assert!(
10835            !is_state_comment(
10836                &format!("{STATE_MARKER}\n{{not json\n-->"),
10837                "spar-bot",
10838                "spar-bot"
10839            ),
10840            "a block that does not parse is not state"
10841        );
10842        assert!(
10843            !is_state_comment(&block, "spar-bot", ""),
10844            "an unknown viewer proves nothing about who wrote it"
10845        );
10846    }
10847
10848    #[test]
10849    fn a_malformed_state_comment_is_none_not_a_panic() {
10850        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
10851    }
10852
10853    #[test]
10854    fn atomic_write_leaves_no_temp_file() {
10855        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
10856        let _ = std::fs::remove_dir_all(&dir);
10857        let path = dir.join("state").join("pr-7.json");
10858        write_json_atomic(&path, &state()).unwrap();
10859        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
10860            .unwrap()
10861            .flatten()
10862            .filter_map(|e| e.file_name().to_str().map(str::to_string))
10863            .collect();
10864        assert_eq!(vec!["pr-7.json".to_string()], files);
10865        let _ = std::fs::remove_dir_all(&dir);
10866    }
10867
10868    #[test]
10869    fn atomic_write_overwrites_rather_than_accumulating() {
10870        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
10871        let _ = std::fs::remove_dir_all(&dir);
10872        let path = dir.join("pr-7.json");
10873        for round in 1..4 {
10874            let mut s = state();
10875            s.round = round;
10876            write_json_atomic(&path, &s).unwrap();
10877        }
10878        let back: PersistedState =
10879            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10880        assert_eq!(3, back.round);
10881        let _ = std::fs::remove_dir_all(&dir);
10882    }
10883
10884    #[test]
10885    fn style_from_env_defaults_to_enforcing() {
10886        std::env::remove_var("SPAR_BAN_EM_DASH");
10887        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
10888        let style = style_from_env();
10889        assert!(style.ban_em_dash && style.ban_ai_attribution);
10890        assert!(
10891            !style.terse,
10892            "the commit filter must not truncate a commit message"
10893        );
10894    }
10895
10896    // -- publishing from a fork --------------------------------------------
10897
10898    /// GitHub's refusal is several lines of advice after the one that says
10899    /// what happened, and spar used to report only the last of them.
10900    #[test]
10901    fn a_push_denial_is_read_from_either_transport() {
10902        let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
10903                   fatal: Could not read from remote repository.\n\n\
10904                   Please make sure you have the correct access rights\n\
10905                   and the repository exists.";
10906        let https = "remote: Permission to example/project.git denied to someone.\n\
10907                     fatal: unable to access 'https://github.com/example/project.git/': The \
10908                     requested URL returned error: 403";
10909        let denied = Some("Permission to example/project.git denied to someone.");
10910        assert_eq!(denied, push_denial(ssh));
10911        assert_eq!(denied, push_denial(https));
10912
10913        let stale = "To github.com:example/project.git\n \
10914                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
10915                     error: failed to push some refs to 'github.com:example/project.git'";
10916        assert_eq!(None, push_denial(stale));
10917
10918        let err = push_error(
10919            &PushTarget::Origin,
10920            "issue-7",
10921            &crate::error::SparError::new(ssh),
10922        );
10923        assert_eq!(
10924            "could not push to origin/issue-7. Permission to example/project.git denied to \
10925             someone.",
10926            err.to_string()
10927        );
10928    }
10929
10930    /// An origin whose hook refuses every push with GitHub's wording, a bare
10931    /// repository standing in for the fork, and a checkout on a new branch
10932    /// with one commit to publish.
10933    #[cfg(unix)]
10934    fn denied_fixture(
10935        tag: &str,
10936        fork_when_denied: bool,
10937    ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
10938        use std::os::unix::fs::PermissionsExt;
10939        use std::sync::atomic::{AtomicU32, Ordering};
10940        static NEXT: AtomicU32 = AtomicU32::new(0);
10941        let id = NEXT.fetch_add(1, Ordering::Relaxed);
10942        let root =
10943            std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
10944        let origin = root.join("origin.git");
10945        let fork = root.join("fork.git");
10946        let work = root.join("work");
10947        for dir in [&origin, &fork, &work] {
10948            std::fs::create_dir_all(dir).unwrap();
10949        }
10950        test_git(&origin, &["init", "--bare", "-b", "main"]);
10951        test_git(&fork, &["init", "--bare", "-b", "main"]);
10952        test_git(&work, &["init", "-b", "main"]);
10953        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
10954        test_git(&work, &["config", "user.name", "spar test"]);
10955        test_git(&work, &["config", "commit.gpgsign", "false"]);
10956        std::fs::write(work.join("README.md"), "seed\n").unwrap();
10957        test_git(&work, &["add", "."]);
10958        test_git(&work, &["commit", "-m", "seed"]);
10959        test_git(
10960            &work,
10961            &["remote", "add", "origin", origin.to_str().unwrap()],
10962        );
10963        test_git(&work, &["push", "-q", "origin", "main"]);
10964        // Installed after the seed, so it is only spar's pushes it refuses.
10965        let hook = origin.join("hooks").join("pre-receive");
10966        std::fs::write(
10967            &hook,
10968            "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
10969        )
10970        .unwrap();
10971        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
10972        test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
10973        std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
10974        test_git(&work, &["add", "."]);
10975        test_git(&work, &["commit", "-m", "Fix it"]);
10976
10977        let mut text =
10978            String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
10979        if !fork_when_denied {
10980            text.push_str("[loop]\nfork_when_denied = false\n");
10981        }
10982        let cfg = crate::config::parse(&text).unwrap();
10983        let repo = Repo::open(&work, &cfg).unwrap();
10984        // What `fork` would have asked GitHub for, so no test reaches it.
10985        repo.fork
10986            .set(Fork {
10987                owner: "someone".into(),
10988                full_name: "someone/project".into(),
10989                url: fork.to_str().unwrap().into(),
10990            })
10991            .unwrap();
10992        (ReviewFixture { root }, repo, work, origin, fork)
10993    }
10994
10995    #[cfg(unix)]
10996    #[test]
10997    fn a_new_branch_origin_refuses_is_published_from_the_fork() {
10998        let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
10999        let first = test_git(&work, &["rev-parse", "HEAD"]);
11000
11001        let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11002
11003        assert_eq!("someone:issue-7", target.head("issue-7"));
11004        assert_eq!(
11005            first.trim(),
11006            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11007        );
11008        assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11009            .trim()
11010            .is_empty());
11011        assert_eq!(
11012            WriteSummary {
11013                attempted: 1,
11014                failed: 0
11015            },
11016            repo.write_summary(),
11017            "a refusal the fork answered is not a failed write"
11018        );
11019
11020        // A later round follows the branch to the fork, leasing what it
11021        // published there.
11022        std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
11023        test_git(&work, &["commit", "-qam", "Fix it again"]);
11024        repo.push_to(&work, &target, "issue-7", Some(first.trim()))
11025            .unwrap();
11026        let second = test_git(&work, &["rev-parse", "HEAD"]);
11027        assert_eq!(
11028            second.trim(),
11029            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11030        );
11031    }
11032
11033    /// What a closed pull request leaves on the fork: a branch with nothing
11034    /// a pull request does not hold. Origin's issue branches are rebuilt over
11035    /// in the same case.
11036    #[cfg(unix)]
11037    #[test]
11038    fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
11039        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
11040        test_git(
11041            &work,
11042            &[
11043                "push",
11044                "-q",
11045                fork.to_str().unwrap(),
11046                "main:refs/heads/issue-7",
11047            ],
11048        );
11049        let head = test_git(&work, &["rev-parse", "HEAD"]);
11050
11051        repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11052
11053        assert_eq!(
11054            head.trim(),
11055            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11056        );
11057        assert_eq!(0, repo.write_summary().failed);
11058    }
11059
11060    #[cfg(unix)]
11061    #[test]
11062    fn work_only_the_fork_has_is_not_published_over() {
11063        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
11064        test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
11065        std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
11066        test_git(&work, &["add", "."]);
11067        test_git(&work, &["commit", "-qm", "Somebody's work"]);
11068        test_git(
11069            &work,
11070            &[
11071                "push",
11072                "-q",
11073                fork.to_str().unwrap(),
11074                "elsewhere:refs/heads/issue-7",
11075            ],
11076        );
11077        let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
11078        test_git(&work, &["checkout", "-q", "issue-7"]);
11079
11080        let err = repo
11081            .publish_new_branch(&work, "issue-7", "main")
11082            .unwrap_err()
11083            .to_string();
11084
11085        assert!(
11086            err.contains(
11087                "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
11088            ),
11089            "{err}"
11090        );
11091        assert!(err.contains("--delete issue-7"), "{err}");
11092        assert_eq!(
11093            theirs.trim(),
11094            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11095        );
11096        assert_eq!(
11097            WriteSummary {
11098                attempted: 1,
11099                failed: 1
11100            },
11101            repo.write_summary()
11102        );
11103    }
11104
11105    /// A lease that loses says which ref and why, not git's closing line.
11106    #[test]
11107    fn a_rejected_push_names_the_ref_it_rejected() {
11108        let stale = "To github.com:example/project.git\n \
11109                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
11110                     error: failed to push some refs to 'github.com:example/project.git'";
11111
11112        let err = push_error(
11113            &PushTarget::Origin,
11114            "issue-7",
11115            &crate::error::SparError::new(stale),
11116        )
11117        .to_string();
11118
11119        assert!(
11120            err.starts_with(
11121                "could not push to origin/issue-7. [rejected]        HEAD -> issue-7 (stale info)"
11122            ),
11123            "{err}"
11124        );
11125    }
11126
11127    #[test]
11128    fn a_fork_is_reached_by_whatever_reaches_origin() {
11129        let cases = [
11130            (
11131                "git@github.com:example/project.git",
11132                Some("git@github.com:someone/project.git"),
11133            ),
11134            // No user: ~/.ssh/config supplies it.
11135            (
11136                "github.com:example/project.git",
11137                Some("github.com:someone/project.git"),
11138            ),
11139            // A host alias, how one machine pushes as two accounts.
11140            (
11141                "git@github-work:example/project.git",
11142                Some("git@github-work:someone/project.git"),
11143            ),
11144            // SSH over 443, where 22 is blocked.
11145            (
11146                "ssh://git@ssh.github.com:443/example/project.git",
11147                Some("ssh://git@ssh.github.com:443/someone/project.git"),
11148            ),
11149            (
11150                "https://github.com/example/project",
11151                Some("https://github.com/someone/project"),
11152            ),
11153            (
11154                "https://ghe.example.com/scm/example/project.git/",
11155                Some("https://ghe.example.com/scm/someone/project.git"),
11156            ),
11157            (
11158                "git@host:/srv/example/project.git",
11159                Some("git@host:/srv/someone/project.git"),
11160            ),
11161            ("/srv/git/project.git", None),
11162            ("https://github.com/project", None),
11163            ("", None),
11164        ];
11165        for (origin, want) in cases {
11166            assert_eq!(
11167                want.map(str::to_string),
11168                fork_url(origin, "someone/project"),
11169                "{origin}"
11170            );
11171        }
11172    }
11173
11174    #[test]
11175    fn a_fork_found_or_made_is_reached_the_way_origin_is() {
11176        let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
11177            "ssh_url":"git@github.com:someone/project.git",
11178            "clone_url":"https://github.com/someone/project.git","fork":true}"#;
11179        let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
11180            "sshUrl":"git@github.com:someone/project.git",
11181            "url":"https://github.com/someone/project"}]"#;
11182
11183        let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
11184        assert_eq!("git@github-work:someone/project.git", fork.url);
11185        assert_eq!("someone", fork.owner);
11186        assert_eq!("someone/project", fork.full_name);
11187        let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
11188            .unwrap()
11189            .unwrap();
11190        assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
11191        assert_eq!(
11192            None,
11193            fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
11194        );
11195
11196        // An origin with no owner/repo path falls back to GitHub's URL, the
11197        // SSH one when origin looks like SSH.
11198        let fork = fork_from_api(made, "https://github.com/project").unwrap();
11199        assert_eq!("https://github.com/someone/project.git", fork.url);
11200        let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
11201        assert_eq!("git@github.com:someone/project.git", fork.url);
11202
11203        assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
11204        assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
11205    }
11206
11207    #[test]
11208    fn only_the_viewers_own_fork_can_be_pushed_to() {
11209        let pr = |owner: &str, name: &str| PrView {
11210            number: 9,
11211            url: String::new(),
11212            title: String::new(),
11213            body: String::new(),
11214            head_ref_name: "issue-7".into(),
11215            base_ref_name: "main".into(),
11216            state: "OPEN".into(),
11217            closing_issues_references: Vec::new(),
11218            is_cross_repository: true,
11219            head_repository_owner: Some(crate::model::Author {
11220                login: owner.into(),
11221            }),
11222            head_repository: Some(crate::model::HeadRepository { name: name.into() }),
11223        };
11224        let origin = "git@github.com:example/project.git";
11225
11226        assert_eq!(
11227            Some(PushTarget::Fork(Fork {
11228                owner: "Someone".into(),
11229                full_name: "Someone/project".into(),
11230                url: "git@github.com:Someone/project.git".into(),
11231            })),
11232            own_fork_target(&pr("Someone", "project"), "someone", origin)
11233        );
11234        assert_eq!(
11235            None,
11236            own_fork_target(&pr("other", "project"), "someone", origin)
11237        );
11238        assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
11239        assert_eq!(
11240            None,
11241            own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
11242        );
11243        let mut unnamed = pr("someone", "project");
11244        unnamed.head_repository_owner = None;
11245        assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
11246    }
11247
11248    /// `spar resume` and `spar checkin` on a pull request from the user's own
11249    /// fork check its branch out from the fork and push back there.
11250    #[cfg(unix)]
11251    #[test]
11252    fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
11253        let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
11254        test_git(
11255            &work,
11256            &[
11257                "push",
11258                "-q",
11259                fork.to_str().unwrap(),
11260                "issue-7:refs/heads/issue-7",
11261            ],
11262        );
11263        let published = test_git(&work, &["rev-parse", "issue-7"]);
11264        test_git(&work, &["checkout", "-q", "main"]);
11265        let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
11266        let pr = PrView {
11267            number: 31,
11268            url: String::new(),
11269            title: String::new(),
11270            body: String::new(),
11271            head_ref_name: "issue-7".into(),
11272            base_ref_name: "main".into(),
11273            state: "OPEN".into(),
11274            closing_issues_references: Vec::new(),
11275            is_cross_repository: true,
11276            head_repository_owner: None,
11277            head_repository: None,
11278        };
11279
11280        let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
11281
11282        assert_eq!("issue-7", branch);
11283        assert_eq!(
11284            published.trim(),
11285            test_git(&path, &["rev-parse", "HEAD"]).trim()
11286        );
11287        std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
11288        test_git(&path, &["commit", "-qam", "Answer a comment"]);
11289        repo.push_to(&path, &target, &branch, Some(published.trim()))
11290            .unwrap();
11291        assert_eq!(
11292            test_git(&path, &["rev-parse", "HEAD"]).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    }
11299
11300    #[cfg(unix)]
11301    #[test]
11302    fn with_forking_off_a_refused_push_says_why_and_how() {
11303        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
11304
11305        let err = repo
11306            .publish_new_branch(&work, "issue-7", "main")
11307            .unwrap_err()
11308            .to_string();
11309
11310        assert!(
11311            err.contains("Permission to example/project.git denied to someone."),
11312            "{err}"
11313        );
11314        assert!(err.contains("fork_when_denied = true"), "{err}");
11315        assert!(test_git(&fork, &["branch", "--list", "issue-7"])
11316            .trim()
11317            .is_empty());
11318        assert_eq!(
11319            WriteSummary {
11320                attempted: 1,
11321                failed: 1
11322            },
11323            repo.write_summary()
11324        );
11325    }
11326}
11327
11328#[cfg(test)]
11329mod comment_page_tests {
11330    use super::*;
11331
11332    #[test]
11333    fn a_single_merged_array_is_read() {
11334        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
11335        assert_eq!(2, pages.len());
11336        assert_eq!(Some(2), pages[1]["id"].as_i64());
11337    }
11338
11339    #[test]
11340    fn concatenated_pages_from_an_older_gh_are_read_too() {
11341        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
11342        assert_eq!(2, pages.len());
11343    }
11344
11345    /// A comment body containing a bracket pair used to split the payload into
11346    /// two invalid halves, so no state comment was found and a resume silently
11347    /// started from round one.
11348    #[test]
11349    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
11350        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
11351        let pages = parse_comment_pages(text);
11352        assert_eq!(2, pages.len(), "{pages:?}");
11353        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
11354    }
11355
11356    #[test]
11357    fn empty_output_is_no_comments_not_a_panic() {
11358        assert!(parse_comment_pages("").is_empty());
11359        assert!(parse_comment_pages("   ").is_empty());
11360        assert!(parse_comment_pages("[]").is_empty());
11361    }
11362
11363    #[test]
11364    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
11365        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
11366    }
11367
11368    #[test]
11369    fn a_write_postcheck_rejects_truncated_comment_pages() {
11370        let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
11371        assert!(
11372            error.to_string().contains("unexpected comment pages"),
11373            "{error}"
11374        );
11375    }
11376
11377    #[test]
11378    fn a_write_postcheck_rejects_empty_or_non_array_output() {
11379        assert!(try_parse_comment_pages("").is_err());
11380        assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
11381        assert!(try_parse_comment_pages("[]").is_ok());
11382    }
11383
11384    #[test]
11385    fn state_is_found_in_the_last_matching_comment() {
11386        let payload = |round: u32| {
11387            format!(
11388                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11389            )
11390        };
11391        let text = serde_json::to_string(&serde_json::json!([
11392            {"id": 1, "body": payload(1)},
11393            {"id": 2, "body": "looks good to me"},
11394            {"id": 3, "body": payload(5)},
11395        ]))
11396        .unwrap();
11397        let pages = parse_comment_pages(&text);
11398        let last = pages
11399            .iter()
11400            .rev()
11401            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
11402            .unwrap();
11403        assert_eq!(5, last.round);
11404    }
11405}
11406
11407#[cfg(test)]
11408mod linked_pr_tests {
11409    use super::*;
11410
11411    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
11412    /// It carries an id and a whole repository object per linked issue, and a
11413    /// parser that chokes on those reports "no pull request", which is the one
11414    /// answer that makes spar implement over the top of somebody's work.
11415    const REAL_PAYLOAD: &str = r#"[
11416      {"number":14252,"title":"fix: reject leading-dash branch names",
11417       "url":"https://github.com/cli/cli/pull/14252",
11418       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
11419         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11420         "url":"https://github.com/cli/cli/issues/14238"}]},
11421      {"number":14217,"title":"another change",
11422       "url":"https://github.com/cli/cli/pull/14217",
11423       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
11424         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11425         "url":"https://github.com/cli/cli/issues/9761"}]},
11426      {"number":14200,"title":"unlinked work",
11427       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
11428    ]"#;
11429
11430    #[test]
11431    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
11432        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
11433        assert_eq!(14252, pr.number);
11434        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
11435    }
11436
11437    #[test]
11438    fn the_right_pr_is_picked_out_of_several() {
11439        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
11440    }
11441
11442    #[test]
11443    fn an_issue_nobody_is_working_on_finds_nothing() {
11444        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
11445    }
11446
11447    #[test]
11448    fn an_unlinked_pr_is_never_matched() {
11449        // 14200 closes nothing, so no issue number should ever return it.
11450        for issue in [14200, 0, 1] {
11451            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
11452                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
11453            }
11454        }
11455    }
11456
11457    #[test]
11458    fn empty_or_broken_output_is_none_rather_than_a_panic() {
11459        assert!(find_linked_pr("", 1).is_none());
11460        assert!(find_linked_pr("[]", 1).is_none());
11461        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
11462        assert!(find_linked_pr("[{\"number\":", 1).is_none());
11463    }
11464
11465    /// "gh could not answer" and "nobody is working on it" are different
11466    /// answers, and only one of them means implement from scratch.
11467    ///
11468    /// The parse used to fold both into `None`, so an authentication failure or
11469    /// a rate limit read as an empty list and spar opened a second pull request
11470    /// for an issue somebody already had work open on.
11471    #[test]
11472    fn an_unreadable_list_is_not_an_empty_list() {
11473        assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
11474        assert!(linked_pr_rows("[{\"number\":").is_none());
11475        assert!(linked_pr_rows("").is_none());
11476
11477        let empty = linked_pr_rows("[]").expect("an empty list is an answer");
11478        assert!(empty.is_empty());
11479        assert!(pick_linked_pr(&empty, 14238).is_none());
11480
11481        let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
11482        assert_eq!(3, listed.len());
11483        assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
11484    }
11485
11486    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
11487    #[test]
11488    fn pr_view_reads_the_cross_repository_flag() {
11489        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
11490                       "baseRefName":"main","state":"OPEN",
11491                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
11492        let pr: PrView = serde_json::from_str(json).unwrap();
11493        assert!(pr.is_cross_repository);
11494        assert!(pr.is_open());
11495
11496        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
11497        assert!(
11498            !serde_json::from_str::<PrView>(&same_repo)
11499                .unwrap()
11500                .is_cross_repository
11501        );
11502    }
11503}
11504
11505#[cfg(test)]
11506mod min_number_tests {
11507    /// The floor is applied before the cap, which is the order that matters.
11508    /// spar takes the *lowest* numbered open items, so a repository with a tail
11509    /// of old issues would otherwise spend its whole run in the tail: the cap
11510    /// would be filled by the oldest items and the floor would never be
11511    /// reached. Filtering first is what makes the setting do anything.
11512    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
11513        let mut numbers: Vec<i64> = open.to_vec();
11514        numbers.sort_unstable();
11515        if min_number > 0 {
11516            numbers.retain(|n| *n >= min_number);
11517        }
11518        numbers.truncate(limit);
11519        numbers
11520    }
11521
11522    #[test]
11523    fn the_floor_is_applied_before_the_cap_not_after() {
11524        let open = [12, 13, 14, 480, 481, 482];
11525        assert_eq!(vec![480, 481], pick(&open, 2, 480));
11526        // Capping first would have returned the two oldest and then filtered
11527        // them all away, leaving nothing.
11528        assert!(!pick(&open, 2, 480).is_empty());
11529    }
11530
11531    #[test]
11532    fn no_floor_keeps_the_old_behaviour() {
11533        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
11534    }
11535
11536    #[test]
11537    fn the_floor_is_inclusive() {
11538        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
11539    }
11540
11541    #[test]
11542    fn a_floor_above_everything_open_yields_nothing() {
11543        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
11544    }
11545}
11546
11547#[cfg(test)]
11548mod review_record_tests {
11549    use super::*;
11550    use serde_json::json;
11551
11552    #[test]
11553    fn prune_state_knows_which_files_belong_to_a_pull_request() {
11554        assert_eq!(Some(12), state_file_number("pr-12.json"));
11555        assert_eq!(Some(12), state_file_number("review-12.json"));
11556        assert_eq!(Some(12), state_file_number("checkin-12.json"));
11557        for shared in [
11558            "spend.json",
11559            "implementors.json",
11560            "contested.json",
11561            "listen.json",
11562            "pr-.json",
11563            "pr-12.json.tmp",
11564            "pr-x.json",
11565        ] {
11566            assert_eq!(None, state_file_number(shared), "{shared}");
11567        }
11568    }
11569
11570    #[test]
11571    fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
11572        let reviews = vec![
11573            json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
11574            json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
11575        ];
11576        assert!(has_viewer_review(&reviews, "me", "the body", "aaa"));
11577        assert!(!has_viewer_review(&reviews, "me", "other", "aaa"));
11578        assert!(!has_viewer_review(&reviews, "alice", "the body", "aaa"));
11579    }
11580
11581    /// A clean review says the same thing on every commit. Taking the approval
11582    /// of an older commit for this one left the new head unapproved while the
11583    /// record said it was.
11584    #[test]
11585    fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
11586        let older = vec![
11587            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
11588        ];
11589        assert!(!has_viewer_review(&older, "me", "the body", "bbb"));
11590        let dismissed = vec![
11591            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
11592        ];
11593        assert!(!has_viewer_review(&dismissed, "me", "the body", "bbb"));
11594    }
11595
11596    #[test]
11597    fn a_response_splits_into_status_headers_and_body() {
11598        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";
11599        let resp = parse_api_response(text).expect("a response");
11600        assert_eq!(200, resp.status);
11601        assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
11602        assert_eq!(
11603            Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
11604            resp.headers.get("last-modified")
11605        );
11606        assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
11607
11608        let not_modified =
11609            parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
11610        assert_eq!(304, not_modified.status);
11611        assert!(not_modified.body.is_empty());
11612
11613        assert_eq!(None, parse_api_response(""));
11614        assert_eq!(None, parse_api_response("gh: command not found\n"));
11615    }
11616
11617    #[test]
11618    fn review_requests_are_read_off_the_timeline_with_who_and_when() {
11619        let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
11620            {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
11621            {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
11622            {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
11623            {}
11624        ]}}}}})
11625        .to_string();
11626        let made = parse_review_requests(&text).expect("requests");
11627        assert_eq!(2, made.len());
11628        assert_eq!("2026-10-20T10:00:00Z", made[0].at);
11629        assert_eq!("viewer", made[0].who.login);
11630        assert!(made[0].who.slug.is_empty());
11631        assert_eq!("2026-10-20T11:00:00Z", made[1].at);
11632        assert!(made[1].who.login.is_empty());
11633        assert_eq!("example/ours", made[1].who.slug);
11634
11635        let none =
11636            json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
11637        assert_eq!(
11638            Some(0),
11639            parse_review_requests(&none.to_string()).map(|m| m.len())
11640        );
11641        assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
11642        assert!(parse_review_requests("gh: not json").is_none());
11643    }
11644
11645    #[test]
11646    fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
11647        let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
11648        let error = crate::error::SparError::call_failed("gh: boom");
11649        assert!(
11650            reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(landed)).is_ok()
11651        );
11652
11653        let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
11654        let err = reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(missing))
11655            .unwrap_err();
11656        assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
11657
11658        let unreadable = Err(crate::error::SparError::new("no network"));
11659        let err = reconcile_review_post(5, "the body", "me", "aaa", error, unreadable).unwrap_err();
11660        assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
11661        assert!(err.to_string().contains("could not be verified"), "{err}");
11662    }
11663    #[test]
11664    fn a_review_with_line_comments_names_the_new_side_and_a_range_only_when_it_has_one() {
11665        let comments = vec![
11666            InlineComment {
11667                path: "src/a.rs".into(),
11668                line: 12,
11669                start_line: None,
11670                body: "one".into(),
11671            },
11672            InlineComment {
11673                path: "src/b.rs".into(),
11674                line: 9,
11675                start_line: Some(4),
11676                body: "two".into(),
11677            },
11678        ];
11679        assert_eq!(
11680            json!({
11681                "commit_id": "abc",
11682                "event": "COMMENT",
11683                "body": "the body",
11684                "comments": [
11685                    {"path": "src/a.rs", "line": 12, "side": "RIGHT", "body": "one"},
11686                    {"path": "src/b.rs", "line": 9, "side": "RIGHT", "start_line": 4,
11687                     "start_side": "RIGHT", "body": "two"},
11688                ],
11689            }),
11690            review_payload("abc", "COMMENT", "the body", &comments)
11691        );
11692    }
11693
11694    #[test]
11695    fn the_new_side_of_each_hunk_is_read_and_its_lines_are_never_taken_for_headers() {
11696        let diff = r#"diff --git a/src/a.rs b/src/a.rs
11697index 1111111..2222222 100644
11698--- a/src/a.rs
11699+++ b/src/a.rs
11700@@ -1,3 +1,4 @@ fn x
11701 one
11702+++ an added line that looks like a header
11703 two
11704 three
11705@@ -20 +21,2 @@
11706-old
11707+new
11708+more
11709@@ -30,1 +32,0 @@
11710-gone
11711diff --git a/gone.rs b/gone.rs
11712deleted file mode 100644
11713--- a/gone.rs
11714+++ /dev/null
11715@@ -1,2 +0,0 @@
11716-a
11717-b
11718diff --git a/old.rs b/renamed.rs
11719similarity index 90%
11720rename from old.rs
11721rename to renamed.rs
11722--- a/old.rs
11723+++ b/renamed.rs
11724@@ -5,0 +6,1 @@
11725+inserted
11726diff --git a/with space.rs b/with space.rs
11727--- a/with space.rs	
11728+++ b/with space.rs	
11729@@ -2 +2 @@
11730-x
11731+y
11732diff --git "a/sp\tace.rs" "b/sp\tace.rs"
11733--- "a/sp\tace.rs"
11734+++ "b/sp\tace.rs"
11735@@ -1 +1 @@
11736-x
11737+y
11738"#;
11739        let hunks = new_side_hunks(diff);
11740        assert_eq!(
11741            Hunks::from([
11742                ("renamed.rs".to_string(), vec![(6, 6)]),
11743                ("src/a.rs".to_string(), vec![(1, 4), (21, 22)]),
11744                ("with space.rs".to_string(), vec![(2, 2)]),
11745            ]),
11746            hunks
11747        );
11748    }
11749
11750    #[test]
11751    fn only_a_refusal_is_final_enough_to_send_a_review_again() {
11752        let error = |text: &str| crate::error::SparError::new(text);
11753        assert!(refused(&error(
11754            "command failed (1): gh api\ngh: Validation Failed (HTTP 422)"
11755        )));
11756        assert!(!refused(&error("gh: Bad Gateway (HTTP 502)")));
11757        assert!(!refused(&error("gh: HTTP 504: Gateway Timeout")));
11758        assert!(!refused(&error("command timed out after 300s")));
11759    }
11760
11761    #[test]
11762    fn a_hunk_header_with_its_counts_left_out_counts_one() {
11763        assert_eq!(Some(((20, 1), (21, 2))), hunk_header("@@ -20 +21,2 @@"));
11764        assert_eq!(Some(((1, 3), (1, 1))), hunk_header("@@ -1,3 +1 @@ fn x"));
11765        assert_eq!(None, hunk_header("@@@ -1,3 -1,3 +1,4 @@@"));
11766        assert_eq!(None, hunk_header(" @@ -1 +1 @@"));
11767    }
11768}