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 answer` records which mentions on an issue it judged.
5729    pub fn answer_state_path(&self, number: i64) -> PathBuf {
5730        self.root
5731            .join(STATE_DIR)
5732            .join("state")
5733            .join(format!("answer-{number}.json"))
5734    }
5735
5736    /// Where `spar answer` keeps what it left for a person to answer. Notes,
5737    /// never posted.
5738    pub fn answer_notes_path(&self, number: i64) -> PathBuf {
5739        self.root
5740            .join(STATE_DIR)
5741            .join("answers")
5742            .join(format!("issue-{number}.md"))
5743    }
5744
5745    /// Where `spar review` records what it reviewed, and a second look reads it.
5746    pub fn review_record_path(&self, number: i64) -> PathBuf {
5747        self.root
5748            .join(STATE_DIR)
5749            .join("state")
5750            .join(format!("review-{number}.json"))
5751    }
5752
5753    /// None when there is no record, or when it cannot be read. A record spar
5754    /// cannot read is one it reviews afresh over, and the log says so.
5755    pub fn read_review_record(&self, number: i64) -> Option<crate::model::ReviewRecord> {
5756        let path = self.review_record_path(number);
5757        let text = std::fs::read_to_string(&path).ok()?;
5758        match serde_json::from_str(&text) {
5759            Ok(record) => Some(record),
5760            Err(e) => {
5761                logdim!("could not read {}: {e}", path.display());
5762                None
5763            }
5764        }
5765    }
5766
5767    pub fn write_review_record(
5768        &self,
5769        number: i64,
5770        record: &crate::model::ReviewRecord,
5771    ) -> Result<()> {
5772        write_json_atomic(&self.review_record_path(number), record)
5773    }
5774
5775    /// Append a follow-up to a local note instead of the tracker.
5776    ///
5777    /// Deduplicated on the title, matching the issue path. The body arrives
5778    /// with its provenance already stamped by the caller, so nothing is added
5779    /// here.
5780    ///
5781    /// A write that did not happen is reported as such rather than as a
5782    /// duplicate: the caller settles the point on the strength of this answer,
5783    /// and settling it on a failed write is how a real defect is lost.
5784    ///
5785    /// Both files are checked, because `spar followup` removes an entry from
5786    /// the queue once it has filed it. Checking only the queue would let the
5787    /// next run that rediscovers the same defect append it again, on top of the
5788    /// issue that now exists for it.
5789    pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
5790        let path = self.followups_path();
5791        let heading = format!("## {}", title.trim());
5792        for seen in [&path, &self.worked_followups_path()] {
5793            if let Ok(existing) = std::fs::read_to_string(seen) {
5794                if has_heading(&existing, title) {
5795                    logdim!("follow-up already noted: {title}");
5796                    return Followup::Covered(format!("note: {}", title.trim()));
5797                }
5798            }
5799        }
5800        if let Some(parent) = path.parent() {
5801            let _ = std::fs::create_dir_all(parent);
5802        }
5803        use std::io::Write;
5804        // The caller already stamped the provenance into the body. Adding
5805        // "From #N." here as well printed it twice, in two different wordings.
5806        //
5807        // The marker above the heading is what makes the entry boundary
5808        // unambiguous to the parser, since the body's own sections are written
5809        // at the same heading level as the title.
5810        let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
5811        match std::fs::OpenOptions::new()
5812            .create(true)
5813            .append(true)
5814            .open(&path)
5815        {
5816            Ok(mut file) => match file.write_all(entry.as_bytes()) {
5817                Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
5818                Err(e) => {
5819                    logdim!("could not write {}: {e}", path.display());
5820                    Followup::Failed
5821                }
5822            },
5823            Err(e) => {
5824                logdim!("could not write {}: {e}", path.display());
5825                Followup::Failed
5826            }
5827        }
5828    }
5829
5830    /// Record what `spar followup` did with an entry, and why.
5831    ///
5832    /// Best effort: an archive that could not be written is not a reason to
5833    /// stop, since the entry has already been filed or ruled on.
5834    pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
5835        let path = self.worked_followups_path();
5836        if let Some(parent) = path.parent() {
5837            let _ = std::fs::create_dir_all(parent);
5838        }
5839        use std::io::Write;
5840        let entry = format!(
5841            "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
5842            title.trim(),
5843            body.trim()
5844        );
5845        if let Ok(mut file) = std::fs::OpenOptions::new()
5846            .create(true)
5847            .append(true)
5848            .open(&path)
5849        {
5850            let _ = file.write_all(entry.as_bytes());
5851        }
5852    }
5853
5854    // -- resumable state --------------------------------------------------
5855    //
5856    // Custody cannot be read from GitHub authorship: every agent commits and
5857    // comments as the same git identity, so `author` is always the human who
5858    // ran spar. State is kept on disk by default and can additionally travel in
5859    // a PR comment, which is what lets a run be resumed from another machine.
5860
5861    /// Where a comment spar produced but did not post is kept.
5862    pub fn pending_comment_path(&self, number: i64) -> PathBuf {
5863        self.root
5864            .join(STATE_DIR)
5865            .join("reviews")
5866            .join(format!("pr-{number}.md"))
5867    }
5868
5869    /// Keep a comment spar decided not to post.
5870    ///
5871    /// A dry run that prints and forgets means agreeing with what you read
5872    /// costs a second full review. Saving it makes the whole point of reading
5873    /// it first: look, edit if you like, then post what you already paid for.
5874    pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
5875        let path = self.pending_comment_path(number);
5876        if let Some(parent) = path.parent() {
5877            std::fs::create_dir_all(parent)
5878                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
5879        }
5880        std::fs::write(&path, text)
5881            .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5882        Ok(path)
5883    }
5884
5885    pub fn read_pending_comment(&self, number: i64) -> Option<String> {
5886        std::fs::read_to_string(self.pending_comment_path(number)).ok()
5887    }
5888
5889    pub fn state_path(&self, number: i64) -> PathBuf {
5890        self.root
5891            .join(STATE_DIR)
5892            .join("state")
5893            .join(format!("pr-{number}.json"))
5894    }
5895
5896    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
5897        let path = self.state_path(number);
5898        let text = std::fs::read_to_string(&path).ok()?;
5899        match serde_json::from_str(&text) {
5900            Ok(state) => Some(state),
5901            Err(_) => {
5902                logdim!("could not read {}, starting fresh", path.display());
5903                None
5904            }
5905        }
5906    }
5907
5908    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
5909        if let Some(local) = self.read_local_state(pr.number) {
5910            return Some(local);
5911        }
5912        if self.state_store.writes_pr() {
5913            return self.read_pr_state(pr.number);
5914        }
5915        None
5916    }
5917
5918    pub(crate) fn read_state_for_head(
5919        &self,
5920        pr: &PrView,
5921        actual_head: &str,
5922    ) -> Option<PersistedState> {
5923        let local = self
5924            .state_store
5925            .writes_local()
5926            .then(|| self.read_local_state(pr.number))
5927            .flatten();
5928        let remote = self
5929            .state_store
5930            .writes_pr()
5931            .then(|| self.read_pr_state(pr.number))
5932            .flatten();
5933        let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
5934        if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
5935            self.remember_checkpoint(pr.number, checkpoint);
5936        }
5937        choose_state_for_head(candidates, actual_head)
5938    }
5939
5940    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
5941        self.try_read_pr_state(number).ok().flatten()
5942    }
5943
5944    fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
5945        for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
5946            if let Some(state) = parse_state_comment(&body) {
5947                return Ok(Some(state));
5948            }
5949        }
5950        Ok(None)
5951    }
5952
5953    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
5954        let remote_state = if self.state_store.writes_pr() {
5955            self.try_read_pr_state(number)
5956        } else {
5957            Ok(None)
5958        };
5959        self.write_state_after_remote_read(number, state, remote_state)
5960    }
5961
5962    fn write_state_after_remote_read(
5963        &self,
5964        number: i64,
5965        state: &PersistedState,
5966        remote_state: Result<Option<PersistedState>>,
5967    ) -> Result<()> {
5968        let remote_checkpoint = if self.state_store.writes_pr() {
5969            self.record_failed_write(remote_state)?
5970                .map(|saved| saved.checkpoint)
5971                .unwrap_or_default()
5972        } else {
5973            0
5974        };
5975        let local_checkpoint = self
5976            .state_store
5977            .writes_local()
5978            .then(|| self.read_local_state(number))
5979            .flatten()
5980            .map(|saved| saved.checkpoint)
5981            .unwrap_or_default();
5982        let mut stamped = state.clone();
5983        stamped.checkpoint = state
5984            .checkpoint
5985            .max(local_checkpoint)
5986            .max(remote_checkpoint)
5987            .max(self.remembered_checkpoint(number))
5988            .saturating_add(1);
5989        self.remember_checkpoint(number, stamped.checkpoint);
5990        if self.state_store.writes_local() {
5991            write_json_atomic(&self.state_path(number), &stamped)?;
5992        }
5993        if self.state_store.writes_pr() {
5994            self.write_pr_state(number, &stamped)?;
5995        }
5996        Ok(())
5997    }
5998
5999    fn remembered_checkpoint(&self, number: i64) -> u64 {
6000        self.checkpoints
6001            .lock()
6002            .unwrap_or_else(std::sync::PoisonError::into_inner)
6003            .get(&number)
6004            .copied()
6005            .unwrap_or_default()
6006    }
6007
6008    fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
6009        let mut checkpoints = self
6010            .checkpoints
6011            .lock()
6012            .unwrap_or_else(std::sync::PoisonError::into_inner);
6013        let saved = checkpoints.entry(number).or_default();
6014        *saved = (*saved).max(checkpoint);
6015    }
6016
6017    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
6018        // Not run through clean(): this is structured data, and scrubbing would
6019        // corrupt refutation text stored in the ledger. It sits inside an
6020        // unclosed HTML comment so GitHub renders it as nothing.
6021        let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
6022        let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
6023        let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
6024        if let Some(id) = comment_id {
6025            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6026            let field = format!("body={body}");
6027            let written = self
6028                .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
6029                .map(|_| ());
6030            return self.record_write(written);
6031        }
6032        let written = self
6033            .gh(&["pr", "comment", &number.to_string(), "--body", &body])
6034            .map(|_| ());
6035        self.record_write(written)
6036    }
6037
6038    /// Top level comments. Works for issues and pull requests alike, because
6039    /// GitHub serves both from the issues endpoint.
6040    ///
6041    /// Nothing when they cannot be read, which suits a reader that is going to
6042    /// go on regardless. A caller deciding whether it has already written here
6043    /// wants `try_issue_comments`, since for that one no comments and no answer
6044    /// are opposite answers.
6045    pub fn issue_comments(&self, number: i64) -> Vec<Value> {
6046        self.try_issue_comments(number).unwrap_or_default()
6047    }
6048
6049    pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
6050        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
6051        try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
6052    }
6053
6054    /// An issue as the REST API returns it, which is the one read that says
6055    /// who filed it in the shape a comment has: `user` with its `type`, and
6056    /// `author_association`, neither of which `gh issue view` gives.
6057    pub fn issue_row(&self, number: i64) -> Result<Value> {
6058        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
6059        let text = self
6060            .gh(&["api", &path])
6061            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
6062        serde_json::from_str(&text)
6063            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
6064    }
6065
6066    /// Open issues in this repository that mention `login`, lowest numbered
6067    /// first.
6068    pub fn open_issues_mentioning(&self, login: &str, limit: usize) -> Result<Vec<i64>> {
6069        #[derive(Deserialize)]
6070        struct Row {
6071            number: i64,
6072        }
6073        // `{owner}/{repo}` is filled in by `gh api` alone.
6074        let name = self
6075            .repo_name()
6076            .ok_or_else(|| spar_err!("could not find out which repository this is"))?;
6077        let text = self.gh(&[
6078            "search",
6079            "issues",
6080            "--repo",
6081            &name,
6082            "--mentions",
6083            login,
6084            "--state",
6085            "open",
6086            "--limit",
6087            &FETCH_CEILING.to_string(),
6088            "--json",
6089            "number",
6090        ])?;
6091        let rows: Vec<Row> = serde_json::from_str(text.trim())
6092            .map_err(|e| spar_err!("unexpected shape for the issues mentioning @{login}: {e}"))?;
6093        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
6094        numbers.sort_unstable();
6095        numbers.truncate(limit);
6096        Ok(numbers)
6097    }
6098
6099    /// spar's own state comments on a pull request, newest last.
6100    ///
6101    /// Three tests, because the answer decides what gets patched over and what
6102    /// gets deleted. A comment matching the marker loosely, which is what
6103    /// somebody asking "why is there a `<!-- spar:state` block on this PR?"
6104    /// writes, is named in the log and left exactly where it is.
6105    fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
6106        let viewer = self.viewer_login()?.to_string();
6107        let mut mine = Vec::new();
6108        let mut theirs = Vec::new();
6109        for comment in self.try_issue_comments(number)? {
6110            let Some(body) = comment.get("body").and_then(Value::as_str) else {
6111                continue;
6112            };
6113            if !body.contains("spar:state") {
6114                continue;
6115            }
6116            let Some(id) = comment.get("id").and_then(Value::as_i64) else {
6117                continue;
6118            };
6119            let author = comment
6120                .get("user")
6121                .and_then(|u| u.get("login"))
6122                .and_then(Value::as_str)
6123                .unwrap_or_default();
6124            if is_state_comment(body, author, &viewer) {
6125                mine.push((id, body.to_string()));
6126            } else {
6127                theirs.push(format!("@{author}"));
6128            }
6129        }
6130        if !theirs.is_empty() {
6131            logdim!(
6132                "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
6133                 own state block is patched or deleted.",
6134                theirs.len(),
6135                theirs.join(", ")
6136            );
6137        }
6138        Ok(mine)
6139    }
6140
6141    fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
6142        Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
6143    }
6144
6145    /// Drop state once the PR is finished and there is nothing to resume.
6146    pub fn clear_state(&self, number: i64) {
6147        let path = self.state_path(number);
6148        let _ = std::fs::remove_file(&path);
6149        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
6150    }
6151
6152    // -- housekeeping -----------------------------------------------------
6153
6154    /// Remove state files whose PR is merged or closed: the custody loop's
6155    /// state, the review record, and the check-in watermark alike. Grouped by
6156    /// number so each finished pull request costs one `gh` read however many
6157    /// files it left.
6158    pub fn prune_state(&self) -> Vec<String> {
6159        let base = self.root.join(STATE_DIR).join("state");
6160        let Ok(entries) = std::fs::read_dir(&base) else {
6161            return Vec::new();
6162        };
6163        let mut by_number: BTreeMap<i64, Vec<String>> = BTreeMap::new();
6164        for name in entries
6165            .flatten()
6166            .filter_map(|e| e.file_name().to_str().map(str::to_string))
6167        {
6168            if let Some(number) = state_file_number(&name) {
6169                by_number.entry(number).or_default().push(name);
6170            }
6171        }
6172
6173        let mut removed = Vec::new();
6174        for (number, mut names) in by_number {
6175            if !is_finished(&self.pr_state(number)) {
6176                continue;
6177            }
6178            names.sort();
6179            for name in names {
6180                let _ = std::fs::remove_file(base.join(&name));
6181                removed.push(format!("state {name}"));
6182            }
6183        }
6184        removed
6185    }
6186
6187    /// Delete state comments from PRs that are finished.
6188    ///
6189    /// Open PRs are left alone: their state may still be live.
6190    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
6191        #[derive(Deserialize)]
6192        struct Row {
6193            number: i64,
6194        }
6195        let numbers = match numbers {
6196            Some(numbers) => numbers,
6197            None => {
6198                let listed: Result<Vec<i64>> = (|| {
6199                    let text = self.gh(&[
6200                        "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
6201                    ])?;
6202                    let rows = serde_json::from_str::<Vec<Row>>(text.trim())
6203                        .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
6204                    Ok(rows.into_iter().map(|row| row.number).collect())
6205                })();
6206                match self.record_failed_write(listed) {
6207                    Ok(numbers) => numbers,
6208                    Err(e) => {
6209                        logdim!("could not inspect pull requests for state cleanup: {e}");
6210                        return Vec::new();
6211                    }
6212                }
6213            }
6214        };
6215
6216        let mut removed = Vec::new();
6217        for number in numbers {
6218            let state = match self.record_failed_write(self.try_pr_state(number)) {
6219                Ok(state) => state,
6220                Err(e) => {
6221                    logdim!("could not inspect PR #{number} for state cleanup: {e}");
6222                    continue;
6223                }
6224            };
6225            if !is_finished(&state) {
6226                continue;
6227            }
6228            let comments = match self.record_failed_write(self.try_state_comments(number)) {
6229                Ok(comments) => comments,
6230                Err(e) => {
6231                    logdim!("could not inspect state comments on PR #{number}: {e}");
6232                    continue;
6233                }
6234            };
6235            for (id, _) in comments {
6236                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
6237                let deleted = self
6238                    .gh(&["api", "-X", "DELETE", &path, "--silent"])
6239                    .map(|_| ());
6240                match self.record_write(deleted) {
6241                    Ok(()) => removed.push(format!("state comment on PR #{number}")),
6242                    Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
6243                }
6244            }
6245        }
6246        removed
6247    }
6248
6249    /// Drop worktrees whose PR is finished, then the branches they left behind.
6250    ///
6251    /// With auto_merge off, which is the default, a run ends at "approved", so
6252    /// nothing would ever clean these up on its own and they accumulate one per
6253    /// run. A stranded worktree also holds its branch checked out, which makes
6254    /// a later `gh pr merge --delete-branch` fail to clean up.
6255    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
6256        let base = self.root.join(WORKTREE_DIR);
6257        let mut removed = Vec::new();
6258        let known = self.known_branches();
6259
6260        if let Ok(entries) = std::fs::read_dir(&base) {
6261            let mut names: Vec<String> = entries
6262                .flatten()
6263                .filter(|e| e.path().is_dir())
6264                .filter_map(|e| e.file_name().to_str().map(str::to_string))
6265                .collect();
6266            names.sort();
6267
6268            for name in names {
6269                // A review worktree is detached and owns no branch, so it is
6270                // tied to the pull request only by its directory name.
6271                if let Some(rest) = name.strip_prefix("review-") {
6272                    let number: i64 = rest.parse().unwrap_or(-1);
6273                    if !(force_all || is_finished(&self.pr_state(number))) {
6274                        continue;
6275                    }
6276                    let path = base.join(&name);
6277                    if force_all {
6278                        let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
6279                            if !belongs {
6280                                return Ok(false);
6281                            }
6282                            let local_ref = review_ref(number);
6283                            if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
6284                                return Ok(false);
6285                            }
6286                            let head = self.head_oid_checked(&path)?;
6287                            let recorded = self
6288                                .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
6289                                .trim()
6290                                .to_string();
6291                            Ok(head == recorded)
6292                        });
6293                        match owned {
6294                            Ok(true) => {}
6295                            Ok(false) => {
6296                                logdim!(
6297                                    "kept {} because no matching SPAR review reference proves \
6298                                     ownership",
6299                                    path.display()
6300                                );
6301                                continue;
6302                            }
6303                            Err(e) => {
6304                                logdim!(
6305                                    "kept {} because review ownership could not be verified: {}",
6306                                    path.display(),
6307                                    e.last_line()
6308                                );
6309                                continue;
6310                            }
6311                        }
6312                    } else {
6313                        if let Err(e) = self.refuse_review_worktree_changes(number) {
6314                            logdim!(
6315                                "kept {} because its review state could not be verified as \
6316                                 disposable: {}",
6317                                path.display(),
6318                                e.last_line()
6319                            );
6320                            continue;
6321                        }
6322                    }
6323                    if force_all {
6324                        if self.remove_worktree_at_force(&path) {
6325                            self.git_try(&["update-ref", "-d", &review_ref(number)]);
6326                        }
6327                    } else {
6328                        self.release_review_worktree(number);
6329                    }
6330                    if !path.exists() {
6331                        removed.push(name);
6332                    }
6333                    continue;
6334                }
6335                let branch = format!("{}{name}", self.branch_prefix);
6336                if !(force_all || self.worktree_is_done(&branch)) {
6337                    continue;
6338                }
6339                if !known.contains_key(&branch) {
6340                    logdim!("kept {branch} because it has no branch record");
6341                    continue;
6342                }
6343                let path = base.join(&name);
6344                if !force_all {
6345                    match self.has_recoverable_work(&path, Disposable::BuildOutput) {
6346                        Ok(true) => {
6347                            logdim!(
6348                                "kept {} because it contains uncommitted changes or ignored files",
6349                                path.display()
6350                            );
6351                            continue;
6352                        }
6353                        Err(e) => {
6354                            logdim!(
6355                                "kept {} because its Git state could not be checked: {}",
6356                                path.display(),
6357                                e.last_line()
6358                            );
6359                            continue;
6360                        }
6361                        Ok(false) => {}
6362                    }
6363                    match self.branch_deletion_is_safe(&branch) {
6364                        Ok(true) => {}
6365                        Ok(false) => {
6366                            logdim!(
6367                                "kept {branch} because no surviving ref preserves its tip or \
6368                                 reflog-only commits"
6369                            );
6370                            continue;
6371                        }
6372                        Err(e) => {
6373                            logdim!(
6374                                "kept {branch} because preservation could not be verified: {}",
6375                                e.last_line()
6376                            );
6377                            continue;
6378                        }
6379                    }
6380                }
6381                let removed_worktree = if force_all {
6382                    self.remove_worktree_at_force(&path)
6383                } else {
6384                    match self.remove_worktree_at(&path) {
6385                        Ok(removed) => removed,
6386                        Err(error) => {
6387                            logdim!(
6388                                "kept {branch} and {} because removal did not reach a confirmed \
6389                                 quiet point: {}",
6390                                path.display(),
6391                                error.last_line()
6392                            );
6393                            false
6394                        }
6395                    }
6396                };
6397                if !removed_worktree {
6398                    continue;
6399                }
6400                if force_all {
6401                    self.git_try(&["branch", "-D", &branch]);
6402                    self.forget_branch(&branch);
6403                } else {
6404                    match self.delete_branch_if_safe(&branch) {
6405                        Ok(true) => self.forget_branch(&branch),
6406                        Ok(false) => logdim!(
6407                            "kept {branch} because its tip or reflog changed before deletion"
6408                        ),
6409                        Err(error) => logdim!(
6410                            "kept {branch} because deletion safety could not be rechecked: {}",
6411                            error.last_line()
6412                        ),
6413                    }
6414                }
6415                removed.push(name);
6416            }
6417        }
6418        removed.extend(self.prune_branches(force_all));
6419        removed
6420    }
6421
6422    /// Delete leftover branches spar created whose worktree is already gone.
6423    ///
6424    /// Deletion is driven by the ledger of branches spar actually created, not
6425    /// by a name pattern. Names default to `issue-N`, which is exactly what a
6426    /// person would call a branch themselves, so a name alone can never
6427    /// establish ownership. This is the data loss guard.
6428    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
6429        let known = self.known_branches();
6430        let branches: Vec<String> = known.keys().cloned().collect();
6431        if branches.is_empty() {
6432            return Vec::new();
6433        }
6434
6435        let checked_out: Vec<String> = self
6436            .git_try(&["worktree", "list", "--porcelain"])
6437            .lines()
6438            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
6439            .collect();
6440
6441        // %(refname:short) is ambiguous when a tag shares the branch name (it
6442        // yields "heads/..."), so take the full ref and strip it here.
6443        let existing: Vec<String> = self
6444            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
6445            .lines()
6446            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
6447            .collect();
6448
6449        let mut removed = Vec::new();
6450        for branch in branches {
6451            if !existing.contains(&branch) {
6452                self.forget_branch(&branch); // already gone, drop the record
6453                continue;
6454            }
6455            if checked_out.contains(&branch) {
6456                continue;
6457            }
6458            if !(force_all || self.worktree_is_done(&branch)) {
6459                continue;
6460            }
6461            if !force_all {
6462                let Some(_record) = known.get(&branch) else {
6463                    continue;
6464                };
6465                match self.branch_deletion_is_safe(&branch) {
6466                    Ok(true) => {}
6467                    Ok(false) => {
6468                        logdim!(
6469                            "kept {branch} because no surviving ref preserves its tip or \
6470                             reflog-only commits"
6471                        );
6472                        continue;
6473                    }
6474                    Err(e) => {
6475                        logdim!(
6476                            "kept {branch} because preservation could not be verified: {}",
6477                            e.last_line()
6478                        );
6479                        continue;
6480                    }
6481                }
6482            }
6483            let deleted = if force_all {
6484                self.git(&["branch", "-D", &branch]).map(|_| true)
6485            } else {
6486                self.delete_branch_if_safe(&branch)
6487            };
6488            match deleted {
6489                Ok(true) => {
6490                    self.forget_branch(&branch);
6491                    removed.push(format!("branch {branch}"));
6492                }
6493                Ok(false) => {
6494                    logdim!("kept {branch} because its tip or reflog changed before deletion");
6495                }
6496                Err(e) => {
6497                    // A branch that silently survives pruning looks like a spar
6498                    // bug, so the name and git's own reason have to be said.
6499                    logdim!("could not delete {branch}: {}", e.last_line());
6500                }
6501            }
6502        }
6503        removed
6504    }
6505
6506    /// True when the PR behind this branch is merged or closed.
6507    fn worktree_is_done(&self, branch: &str) -> bool {
6508        #[derive(Deserialize)]
6509        struct Row {
6510            state: String,
6511        }
6512        let entry = branch
6513            .strip_prefix(self.branch_prefix.as_str())
6514            .unwrap_or(branch);
6515        if let Some(rest) = entry.strip_prefix("pr-") {
6516            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
6517        }
6518        // A split part is the same shape as an issue branch: one branch, whose
6519        // pull requests say whether it is finished. Without it here, a part
6520        // branch is one nothing but `clean --all` would ever remove.
6521        if entry.starts_with("issue-") || entry.starts_with("split-") {
6522            let text = self.gh_try(&[
6523                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
6524            ]);
6525            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
6526            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
6527        }
6528        false
6529    }
6530}
6531
6532// ---------------------------------------------------------------------------
6533// Free helpers
6534// ---------------------------------------------------------------------------
6535
6536/// Take a read-only probe again before treating its failure as evidence.
6537///
6538/// Every probe is a Git invocation under a thirty second cap plus a walk over a
6539/// tree an agent may still be building in. A probe that fails proves nothing
6540/// about the worktree, and there is a real difference between "the tree moved"
6541/// and "the question could not be asked just then", which the caller turns into
6542/// the same run-ending error.
6543pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
6544    match probe() {
6545        Ok(value) => Ok(value),
6546        Err(first) => {
6547            logdim!("retrying a worktree probe after: {}", first.last_line());
6548            probe()
6549        }
6550    }
6551}
6552
6553/// Read attribute files without asking Git to inspect working-tree content.
6554///
6555/// A newly written attribute can select a clean or smudge filter. It must be
6556/// detected before a post-call status, diff, or add command has a chance to run
6557/// that filter in the parent process.
6558pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
6559    let root = std::fs::canonicalize(cwd)
6560        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6561    let mut files = BTreeMap::new();
6562    let mut visited = BTreeSet::new();
6563    collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
6564    Ok(AttributeState { files })
6565}
6566
6567fn collect_attribute_files(
6568    root: &Path,
6569    repository: &Path,
6570    prefix: &Path,
6571    visited: &mut BTreeSet<PathBuf>,
6572    files: &mut BTreeMap<PathBuf, [u8; 32]>,
6573) -> Result<()> {
6574    let canonical = std::fs::canonicalize(repository)
6575        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6576    if !visited.insert(canonical) {
6577        bail!("submodule recursion revisited {}", repository.display());
6578    }
6579    let entries = index_entries(repository)?;
6580    let mut paths: BTreeSet<PathBuf> = entries
6581        .iter()
6582        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6583        .map(|entry| entry.path.clone())
6584        .collect();
6585    // Ignored on purpose. A `.gitattributes` under build output can only select
6586    // filters for paths that are themselves ignored, and no ignored path is
6587    // ever staged or compared as tracked content, so it cannot reach a managed
6588    // commit. Capturing it meant `cargo package` or `npm ci` writing one into
6589    // its own output ended a read-only call outright.
6590    paths.extend(untracked_attribute_paths(repository)?);
6591    for path in paths {
6592        let from_root = prefix.join(&path);
6593        let state = attribute_file_fingerprint(&root.join(&from_root))?;
6594        files.insert(from_root, state);
6595    }
6596    for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
6597        let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
6598            continue;
6599        };
6600        collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
6601    }
6602    Ok(())
6603}
6604
6605/// Leave a visible, untracked reason ordinary cleanup can detect on a later
6606/// run even when Git status normalizes the original working-file change away.
6607pub(crate) fn uncertain_worktree_change(
6608    cwd: &Path,
6609    message: impl Into<String>,
6610) -> crate::error::SparError {
6611    let message = message.into();
6612    let marker = write_recovery_marker(cwd, &message);
6613    let note = match marker {
6614        Ok(path) => format!(" Recovery marker: {}.", path.display()),
6615        Err(e) => format!(
6616            " A recovery marker could not be written: {}.",
6617            e.last_line()
6618        ),
6619    };
6620    crate::error::SparError::uncertain_write(format!("{message}{note}"))
6621}
6622
6623fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
6624    use std::sync::atomic::{AtomicU32, Ordering};
6625    static NEXT: AtomicU32 = AtomicU32::new(0);
6626    for _ in 0..1000 {
6627        let serial = NEXT.fetch_add(1, Ordering::Relaxed);
6628        let path = cwd.join(format!(
6629            "{RECOVERY_MARKER_PREFIX}{}-{serial}",
6630            std::process::id()
6631        ));
6632        let mut options = OpenOptions::new();
6633        options.write(true).create_new(true);
6634        #[cfg(unix)]
6635        {
6636            use std::os::unix::fs::OpenOptionsExt;
6637            options.mode(0o600);
6638        }
6639        match options.open(&path) {
6640            Ok(mut file) => {
6641                file.write_all(detail.as_bytes())
6642                    .and_then(|_| file.write_all(b"\n"))
6643                    .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
6644                return Ok(path);
6645            }
6646            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
6647            Err(e) => {
6648                return Err(spar_err!(
6649                    "could not create a recovery marker in {}: {e}",
6650                    cwd.display()
6651                ))
6652            }
6653        }
6654    }
6655    bail!(
6656        "could not choose a free recovery marker name in {}",
6657        cwd.display()
6658    )
6659}
6660
6661/// Build a Git command that cannot launch automatic repository maintenance.
6662///
6663/// A fetch may otherwise prune missing linked worktree registrations. SPAR
6664/// must only remove registrations it has proven it owns.
6665fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
6666    let mut argv = vec![
6667        "git".to_string(),
6668        "-c".to_string(),
6669        "maintenance.auto=false".to_string(),
6670        "-c".to_string(),
6671        "gc.auto=0".to_string(),
6672    ];
6673    argv.extend(args.iter().map(|arg| (*arg).to_string()));
6674    argv
6675}
6676
6677fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
6678    let mut argv = git_without_maintenance_argv(&[]);
6679    argv.extend([
6680        "-c".to_string(),
6681        "core.fsmonitor=".to_string(),
6682        "-c".to_string(),
6683        "commit.gpgsign=false".to_string(),
6684        "-c".to_string(),
6685        "core.hooksPath=/dev/null".to_string(),
6686    ]);
6687    argv.extend(args.iter().map(|arg| (*arg).to_string()));
6688    argv
6689}
6690
6691/// Snapshot every untracked file, including ignored files, without changing
6692/// path bytes.
6693///
6694/// Without an exclude option, Git lists both ordinary and ignored untracked
6695/// entries. Metadata fingerprints make overwriting an existing path observable
6696/// without hashing a potentially multi-gigabyte build tree on every call.
6697pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
6698    let root = std::fs::canonicalize(cwd)
6699        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6700    let mut files = BTreeMap::new();
6701    let mut ignored = BTreeSet::new();
6702    let mut visited = BTreeSet::new();
6703    collect_untracked_files(
6704        &root,
6705        &root,
6706        Path::new(""),
6707        &mut visited,
6708        &mut files,
6709        &mut ignored,
6710    )?;
6711    Ok(IgnoredState { files, ignored })
6712}
6713
6714fn collect_untracked_files(
6715    root: &Path,
6716    repository: &Path,
6717    prefix: &Path,
6718    visited: &mut BTreeSet<PathBuf>,
6719    files: &mut BTreeMap<PathBuf, UntrackedFile>,
6720    ignored: &mut BTreeSet<PathBuf>,
6721) -> Result<()> {
6722    let canonical = std::fs::canonicalize(repository)
6723        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6724    if !visited.insert(canonical.clone()) {
6725        bail!("submodule recursion revisited {}", canonical.display());
6726    }
6727    // Classify ordinary paths in the listing itself. Listing every path here
6728    // and marking ignored paths in a second query misclassifies build output
6729    // deleted between queries as ordinary untracked work.
6730    let listed = run_git_bytes(
6731        repository,
6732        &["ls-files", "--others", "--exclude-standard", "-z"],
6733    )?;
6734    if !listed.is_empty() && !listed.ends_with(&[0]) {
6735        bail!(
6736            "git returned an unterminated untracked-file list for {}",
6737            repository.display()
6738        );
6739    }
6740
6741    for raw in listed
6742        .split(|byte| *byte == 0)
6743        .filter(|raw| !raw.is_empty())
6744    {
6745        let (relative, nested) = untracked_record(raw, "untracked")?;
6746        let from_root = prefix.join(&relative);
6747        if is_spar_own_path(&from_root) {
6748            continue;
6749        }
6750        let absolute = root.join(&from_root);
6751        let fingerprint = if nested {
6752            nested_repository_fingerprint(&absolute)?
6753        } else {
6754            ignored_file_fingerprint(&absolute)?
6755        };
6756        if files.insert(from_root.clone(), fingerprint).is_some() {
6757            bail!(
6758                "git returned the untracked path more than once: {:?}",
6759                from_root
6760            );
6761        }
6762    }
6763
6764    let ignored_listed = run_git_bytes(
6765        repository,
6766        &[
6767            "ls-files",
6768            "--others",
6769            "--ignored",
6770            "--exclude-standard",
6771            "-z",
6772        ],
6773    )?;
6774    if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
6775        bail!(
6776            "git returned an unterminated ignored-file list for {}",
6777            repository.display()
6778        );
6779    }
6780    // Two `ls-files` runs cannot be made to agree about a tree that is being
6781    // built in. A path named only by the second one was created between them,
6782    // which is a fact about the moment, not a corruption: record it and let the
6783    // comparison decide, rather than ending a run that had nothing wrong with
6784    // it.
6785    for raw in ignored_listed
6786        .split(|byte| *byte == 0)
6787        .filter(|raw| !raw.is_empty())
6788    {
6789        let (relative, nested) = untracked_record(raw, "ignored")?;
6790        let from_root = prefix.join(relative);
6791        if is_spar_own_path(&from_root) {
6792            continue;
6793        }
6794        if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
6795            let absolute = root.join(&from_root);
6796            let fingerprint = if nested {
6797                nested_repository_fingerprint(&absolute)?
6798            } else {
6799                ignored_file_fingerprint(&absolute)?
6800            };
6801            slot.insert(fingerprint);
6802        }
6803        if !ignored.insert(from_root.clone()) {
6804            bail!(
6805                "git returned the ignored path more than once: {:?}",
6806                from_root
6807            );
6808        }
6809    }
6810
6811    for link in gitlinks(repository)? {
6812        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6813            continue;
6814        };
6815        collect_untracked_files(
6816            root,
6817            &submodule,
6818            &prefix.join(&link.path),
6819            visited,
6820            files,
6821            ignored,
6822        )?;
6823    }
6824    Ok(())
6825}
6826
6827fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
6828    let argv = git_without_automation_argv(args);
6829    proc::run_bytes(
6830        &argv,
6831        &ExecOpts::new()
6832            .cwd(cwd)
6833            .timeout_secs(30)
6834            .stop_descendants(true),
6835    )
6836}
6837
6838fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
6839    let argv = git_without_automation_argv(args);
6840    proc::run(
6841        &argv,
6842        &ExecOpts::new()
6843            .cwd(cwd)
6844            .timeout_secs(30)
6845            .stop_descendants(true),
6846    )
6847}
6848
6849fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
6850    let path = path.to_str().ok_or_else(|| {
6851        spar_err!(
6852            "cannot verify filtered content for a non-UTF-8 path in {}",
6853            cwd.display()
6854        )
6855    })?;
6856    let path_arg = format!("--path={path}");
6857    let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
6858    Ok(Sha256::digest(bytes).into())
6859}
6860
6861fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
6862    let relative = path_from_git_bytes(raw)?;
6863    if relative.is_absolute()
6864        || relative.components().any(|component| {
6865            matches!(
6866                component,
6867                std::path::Component::ParentDir
6868                    | std::path::Component::RootDir
6869                    | std::path::Component::Prefix(_)
6870            )
6871        })
6872    {
6873        bail!("git returned an unsafe {kind} path: {:?}", relative);
6874    }
6875    Ok(relative)
6876}
6877
6878/// Split one `ls-files --others` record into its path and whether Git reported
6879/// a nested repository rather than a single file.
6880///
6881/// Git never lists the contents of a repository inside the working tree, so a
6882/// checkout parked there, such as another of SPAR's own worktrees, arrives as
6883/// one record for the directory itself ending in a separator. Git writes that
6884/// separator on every platform. Trimming it keeps the recorded path equal to
6885/// the same path seen any other way.
6886fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
6887    let nested = raw.last() == Some(&b'/');
6888    let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
6889    if trimmed.is_empty() {
6890        bail!("git returned an empty {kind} path");
6891    }
6892    Ok((safe_git_path(trimmed, kind)?, nested))
6893}
6894
6895fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
6896    let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
6897    if !listed.is_empty() && !listed.ends_with(&[0]) {
6898        bail!(
6899            "git returned an unterminated index listing for {}",
6900            cwd.display()
6901        );
6902    }
6903    let mut entries = Vec::new();
6904    for record in listed
6905        .split(|byte| *byte == 0)
6906        .filter(|record| !record.is_empty())
6907    {
6908        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6909            bail!(
6910                "git returned a malformed index record for {}",
6911                cwd.display()
6912            );
6913        };
6914        let header = &record[..tab];
6915        let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
6916        if fields.len() != 3 {
6917            bail!(
6918                "git returned a malformed index header for {}",
6919                cwd.display()
6920            );
6921        }
6922        if fields[2] != b"0" {
6923            continue;
6924        }
6925        let mode = std::str::from_utf8(fields[0])
6926            .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
6927            .to_string();
6928        let oid = std::str::from_utf8(fields[1])
6929            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6930            .to_string();
6931        entries.push(IndexEntry {
6932            path: safe_git_path(&record[tab + 1..], "index")?,
6933            mode,
6934            oid,
6935        });
6936    }
6937    Ok(entries)
6938}
6939
6940/// Share Git's complete ignore policy between call checkpoints and handoffs.
6941fn untracked_attribute_paths(cwd: &Path) -> Result<Vec<PathBuf>> {
6942    let listed = run_git_bytes(
6943        cwd,
6944        &[
6945            "ls-files",
6946            "--others",
6947            "--exclude-standard",
6948            "-z",
6949            "--",
6950            ".gitattributes",
6951            ":(glob)**/.gitattributes",
6952        ],
6953    )?;
6954    if !listed.is_empty() && !listed.ends_with(&[0]) {
6955        bail!(
6956            "git returned an unterminated attribute-file listing for {}",
6957            cwd.display()
6958        );
6959    }
6960    listed
6961        .split(|byte| *byte == 0)
6962        .filter(|record| !record.is_empty())
6963        .map(|raw| safe_git_path(raw, "attribute"))
6964        .collect()
6965}
6966
6967fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
6968    if !untracked_attribute_paths(cwd)?.is_empty() {
6969        return Ok(true);
6970    }
6971
6972    let index = index_entries(cwd)?
6973        .into_iter()
6974        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6975        .map(|entry| (entry.path, (entry.mode, entry.oid)))
6976        .collect::<BTreeMap<_, _>>();
6977    let head = tree_entries(cwd, "HEAD")?
6978        .into_iter()
6979        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
6980        .map(|entry| (entry.path, (entry.mode, entry.oid)))
6981        .collect::<BTreeMap<_, _>>();
6982    if index != head {
6983        return Ok(true);
6984    }
6985
6986    let effective = check_attributes(cwd, index.keys().cloned())?;
6987    let config = CheckoutConfig::read(cwd)?;
6988    for (path, (_mode, oid)) in index {
6989        let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
6990            return Ok(true);
6991        };
6992        let attributes = effective
6993            .get(&path)
6994            .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
6995        if allows_expected_crlf(&config, attributes)? {
6996            if worktree.mode == "120000" {
6997                return Ok(true);
6998            }
6999            let (normalized, every_lf_was_crlf) =
7000                normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
7001            if !every_lf_was_crlf || normalized != oid {
7002                return Ok(true);
7003            }
7004        } else if worktree.raw_oid != oid {
7005            return Ok(true);
7006        }
7007    }
7008    Ok(false)
7009}
7010
7011fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
7012    Ok(index_entries(cwd)?
7013        .into_iter()
7014        .filter(|entry| entry.mode == "160000")
7015        .map(|entry| Gitlink {
7016            path: entry.path,
7017            oid: entry.oid,
7018        })
7019        .collect())
7020}
7021
7022fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
7023    let mut tracked = BTreeMap::new();
7024    for entry in index_entries(cwd)? {
7025        if entry.mode == "160000" {
7026            continue;
7027        }
7028        let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
7029        tracked.insert(
7030            entry.path,
7031            TrackedEntry {
7032                index_mode: entry.mode,
7033                index_oid: entry.oid,
7034                worktree,
7035            },
7036        );
7037    }
7038    Ok(tracked)
7039}
7040
7041fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
7042    let metadata = match std::fs::symlink_metadata(path) {
7043        Ok(metadata) => metadata,
7044        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7045        Err(e) => {
7046            return Err(spar_err!(
7047                "could not inspect tracked file {}: {e}",
7048                path.display()
7049            ))
7050        }
7051    };
7052    let mut fingerprint = Sha256::new();
7053    if metadata.file_type().is_symlink() {
7054        let target = std::fs::read_link(path)
7055            .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
7056        let bytes = os_str_bytes(target.as_os_str())?;
7057        fingerprint.update(b"symlink\0");
7058        fingerprint.update(&bytes);
7059        let content = Sha256::digest(&bytes).into();
7060        return Ok(Some(WorktreeFile {
7061            mode: "120000".to_string(),
7062            #[cfg(unix)]
7063            permissions: 0,
7064            raw_oid: git_blob_oid(oid_len, &bytes)?,
7065            fingerprint: fingerprint.finalize().into(),
7066            content,
7067        }));
7068    }
7069    if !metadata.is_file() {
7070        bail!("tracked path {} is not a file or symlink", path.display());
7071    }
7072
7073    let mut options = OpenOptions::new();
7074    options.read(true);
7075    #[cfg(unix)]
7076    {
7077        use std::os::unix::fs::OpenOptionsExt;
7078        options.custom_flags(libc::O_NOFOLLOW);
7079    }
7080    let mut file = options
7081        .open(path)
7082        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7083    let before = file
7084        .metadata()
7085        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7086    let mode = tracked_file_mode(&before);
7087    #[cfg(unix)]
7088    let permissions = {
7089        use std::os::unix::fs::MetadataExt;
7090        before.mode() & 0o7777
7091    };
7092    fingerprint.update(b"file\0");
7093    fingerprint.update(mode.as_bytes());
7094    #[cfg(unix)]
7095    fingerprint.update(permissions.to_le_bytes());
7096    fingerprint.update(before.len().to_le_bytes());
7097    let mut content = Sha256::new();
7098    let header = format!("blob {}\0", before.len());
7099    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7100    let mut buf = [0u8; 64 * 1024];
7101    loop {
7102        let read = file
7103            .read(&mut buf)
7104            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7105        if read == 0 {
7106            break;
7107        }
7108        fingerprint.update(&buf[..read]);
7109        content.update(&buf[..read]);
7110        object.update(&buf[..read]);
7111    }
7112    let after = file
7113        .metadata()
7114        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7115    if before.len() != after.len()
7116        || before.modified().ok() != after.modified().ok()
7117        || before.permissions() != after.permissions()
7118    {
7119        bail!(
7120            "tracked file {} changed while it was being inspected",
7121            path.display()
7122        );
7123    }
7124    let current = std::fs::symlink_metadata(path)
7125        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7126    if !same_file(&after, &current) {
7127        bail!(
7128            "tracked file {} was replaced while it was being inspected",
7129            path.display()
7130        );
7131    }
7132    Ok(Some(WorktreeFile {
7133        mode,
7134        #[cfg(unix)]
7135        permissions,
7136        raw_oid: object.finish(),
7137        fingerprint: fingerprint.finalize().into(),
7138        content: content.finalize().into(),
7139    }))
7140}
7141
7142fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
7143    let metadata = std::fs::symlink_metadata(path)
7144        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7145    let mut digest = Sha256::new();
7146    if metadata.file_type().is_symlink() {
7147        digest.update(b"symlink\0");
7148        let target = std::fs::read_link(path)
7149            .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
7150        digest.update(os_str_bytes(target.as_os_str())?);
7151        return Ok(digest.finalize().into());
7152    }
7153    if !metadata.is_file() {
7154        bail!("attribute path {} is not a file or symlink", path.display());
7155    }
7156    let mut options = OpenOptions::new();
7157    options.read(true);
7158    #[cfg(unix)]
7159    {
7160        use std::os::unix::fs::OpenOptionsExt;
7161        options.custom_flags(libc::O_NOFOLLOW);
7162    }
7163    let mut file = options
7164        .open(path)
7165        .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7166    let before = file
7167        .metadata()
7168        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
7169    digest.update(b"file\0");
7170    let mut buf = [0u8; 64 * 1024];
7171    loop {
7172        let read = file
7173            .read(&mut buf)
7174            .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
7175        if read == 0 {
7176            break;
7177        }
7178        digest.update(&buf[..read]);
7179    }
7180    let after = file
7181        .metadata()
7182        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7183    let current = std::fs::symlink_metadata(path)
7184        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
7185    if before.len() != after.len()
7186        || before.modified().ok() != after.modified().ok()
7187        || !same_file(&after, &current)
7188    {
7189        bail!(
7190            "attribute file {} changed while it was being inspected",
7191            path.display()
7192        );
7193    }
7194    Ok(digest.finalize().into())
7195}
7196
7197enum ObjectHasher {
7198    Sha1(Sha1),
7199    Sha256(Sha256),
7200}
7201
7202impl ObjectHasher {
7203    fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
7204        let mut hasher = match oid_len {
7205            40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
7206            64 => Self::Sha256(Sha256::new()),
7207            _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
7208        };
7209        hasher.update(header);
7210        Ok(hasher)
7211    }
7212
7213    fn update(&mut self, bytes: &[u8]) {
7214        match self {
7215            Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
7216            Self::Sha256(hasher) => hasher.update(bytes),
7217        }
7218    }
7219
7220    fn finish(self) -> String {
7221        let bytes = match self {
7222            Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
7223            Self::Sha256(hasher) => hasher.finalize().to_vec(),
7224        };
7225        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
7226    }
7227}
7228
7229fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
7230    let header = format!("blob {}\0", bytes.len());
7231    let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
7232    hasher.update(bytes);
7233    Ok(hasher.finish())
7234}
7235
7236fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
7237    let mut first = open_regular_file(path)?;
7238    let first_before = first
7239        .metadata()
7240        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7241    let mut raw_len = 0u64;
7242    let mut crlf_pairs = 0u64;
7243    let mut previous_was_cr = false;
7244    let mut every_lf_was_crlf = true;
7245    let mut buf = [0u8; 64 * 1024];
7246    loop {
7247        let read = first
7248            .read(&mut buf)
7249            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7250        if read == 0 {
7251            break;
7252        }
7253        raw_len = raw_len
7254            .checked_add(read as u64)
7255            .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
7256        for byte in &buf[..read] {
7257            if *byte == b'\n' {
7258                if previous_was_cr {
7259                    crlf_pairs += 1;
7260                } else {
7261                    every_lf_was_crlf = false;
7262                }
7263            }
7264            previous_was_cr = *byte == b'\r';
7265        }
7266    }
7267    let first_after = first
7268        .metadata()
7269        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7270    let current = std::fs::symlink_metadata(path)
7271        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7272    if raw_len != first_before.len()
7273        || !stable_file_metadata(&first_before, &first_after)
7274        || !stable_file_metadata(&first_after, &current)
7275    {
7276        bail!(
7277            "tracked file {} changed while line endings were inspected",
7278            path.display()
7279        );
7280    }
7281
7282    let normalized_len = raw_len
7283        .checked_sub(crlf_pairs)
7284        .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
7285    let header = format!("blob {normalized_len}\0");
7286    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
7287    let mut second = open_regular_file(path)?;
7288    let second_before = second
7289        .metadata()
7290        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7291    if !stable_file_metadata(&first_after, &second_before) {
7292        bail!(
7293            "tracked file {} changed between line-ending checks",
7294            path.display()
7295        );
7296    }
7297    let mut pending_cr = false;
7298    loop {
7299        let read = second
7300            .read(&mut buf)
7301            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7302        if read == 0 {
7303            break;
7304        }
7305        for byte in &buf[..read] {
7306            if pending_cr {
7307                if *byte == b'\n' {
7308                    object.update(b"\n");
7309                    pending_cr = false;
7310                    continue;
7311                }
7312                object.update(b"\r");
7313                pending_cr = false;
7314            }
7315            if *byte == b'\r' {
7316                pending_cr = true;
7317            } else {
7318                object.update(std::slice::from_ref(byte));
7319            }
7320        }
7321    }
7322    if pending_cr {
7323        object.update(b"\r");
7324    }
7325    let second_after = second
7326        .metadata()
7327        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7328    let current = std::fs::symlink_metadata(path)
7329        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
7330    if !stable_file_metadata(&second_before, &second_after)
7331        || !stable_file_metadata(&second_after, &current)
7332    {
7333        bail!(
7334            "tracked file {} changed while line endings were hashed",
7335            path.display()
7336        );
7337    }
7338    Ok((object.finish(), every_lf_was_crlf))
7339}
7340
7341fn open_regular_file(path: &Path) -> Result<std::fs::File> {
7342    let mut options = OpenOptions::new();
7343    options.read(true);
7344    #[cfg(unix)]
7345    {
7346        use std::os::unix::fs::OpenOptionsExt;
7347        options.custom_flags(libc::O_NOFOLLOW);
7348    }
7349    let file = options
7350        .open(path)
7351        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
7352    let metadata = file
7353        .metadata()
7354        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
7355    if !metadata.is_file() {
7356        bail!("tracked path {} is not a regular file", path.display());
7357    }
7358    Ok(file)
7359}
7360
7361fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7362    if !same_file(left, right)
7363        || left.len() != right.len()
7364        || left.modified().ok() != right.modified().ok()
7365        || left.permissions() != right.permissions()
7366    {
7367        return false;
7368    }
7369    #[cfg(unix)]
7370    {
7371        use std::os::unix::fs::MetadataExt;
7372        left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
7373    }
7374    #[cfg(not(unix))]
7375    {
7376        left.created().ok() == right.created().ok()
7377    }
7378}
7379
7380/// How many paths one `check-attr` call is asked about.
7381///
7382/// `check-attr` answers with three NUL fields per attribute per path, so the
7383/// output is several times the input. A whole monorepo in one call is megabytes
7384/// in both directions, and the 30 second timeout below is a timeout on the
7385/// batch. Batching keeps each call small enough to be honest about.
7386const ATTRIBUTE_BATCH: usize = 1000;
7387
7388fn check_attributes(
7389    cwd: &Path,
7390    paths: impl IntoIterator<Item = PathBuf>,
7391) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7392    let paths = paths.into_iter().collect::<BTreeSet<_>>();
7393    if paths.is_empty() {
7394        return Ok(BTreeMap::new());
7395    }
7396    let ordered = paths.into_iter().collect::<Vec<_>>();
7397    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7398    for batch in ordered.chunks(ATTRIBUTE_BATCH) {
7399        values.extend(check_attribute_batch(cwd, batch)?);
7400    }
7401    Ok(values)
7402}
7403
7404fn check_attribute_batch(
7405    cwd: &Path,
7406    batch: &[PathBuf],
7407) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
7408    const NAMES: [&str; 6] = [
7409        "filter",
7410        "working-tree-encoding",
7411        "ident",
7412        "text",
7413        "eol",
7414        "crlf",
7415    ];
7416    let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
7417    let mut input = String::new();
7418    for path in &paths {
7419        let path = path.to_str().ok_or_else(|| {
7420            spar_err!(
7421                "cannot inspect attributes for a non-UTF-8 path in {}",
7422                cwd.display()
7423            )
7424        })?;
7425        input.push_str(path);
7426        input.push('\0');
7427    }
7428    let argv = git_without_automation_argv(&[
7429        "check-attr",
7430        "-z",
7431        "--cached",
7432        "--stdin",
7433        "filter",
7434        "working-tree-encoding",
7435        "ident",
7436        "text",
7437        "eol",
7438        "crlf",
7439    ]);
7440    let output = proc::run_bytes(
7441        &argv,
7442        &ExecOpts::new()
7443            .cwd(cwd)
7444            .timeout_secs(30)
7445            .stdin(input)
7446            .stop_descendants(true),
7447    )?;
7448    if !output.is_empty() && !output.ends_with(&[0]) {
7449        bail!(
7450            "git returned an unterminated attribute result for {}",
7451            cwd.display()
7452        );
7453    }
7454    let fields = output
7455        .split(|byte| *byte == 0)
7456        .filter(|field| !field.is_empty())
7457        .collect::<Vec<_>>();
7458    if fields.len() != paths.len() * NAMES.len() * 3 {
7459        bail!(
7460            "git returned an unexpected attribute result for {}",
7461            cwd.display()
7462        );
7463    }
7464    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
7465    for record in fields.chunks_exact(3) {
7466        let path = safe_git_path(record[0], "attribute")?;
7467        if !paths.contains(&path) {
7468            bail!(
7469                "git returned attributes for the wrong path in {}",
7470                cwd.display()
7471            );
7472        }
7473        let name = std::str::from_utf8(record[1])
7474            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
7475        let value = std::str::from_utf8(record[2])
7476            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
7477        values
7478            .entry(path)
7479            .or_default()
7480            .insert(name.to_string(), value.to_string());
7481    }
7482    if paths.iter().any(|path| {
7483        values
7484            .get(path)
7485            .is_none_or(|attributes| attributes.len() != NAMES.len())
7486    }) {
7487        bail!(
7488            "git omitted an attribute result for a tracked path in {}",
7489            cwd.display()
7490        );
7491    }
7492    Ok(values)
7493}
7494
7495fn attribute_is_active(value: Option<&String>) -> bool {
7496    !matches!(
7497        value.map(String::as_str),
7498        None | Some("unspecified") | Some("unset")
7499    )
7500}
7501
7502fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
7503    attribute_is_active(values.get("filter"))
7504        || attribute_is_active(values.get("working-tree-encoding"))
7505}
7506
7507/// The checkout settings a per-file line-ending decision depends on.
7508///
7509/// These are constant for a worktree, but the checks that read them run once
7510/// per tracked file, and each read was its own `git config` process. A
7511/// thousand-file repository with no `.gitattributes` takes every one of those
7512/// branches, which is half a minute of process spawning per scan, and the sweep
7513/// before a run scans every finished worktree more than once. Read them here,
7514/// once, and hand them down.
7515struct CheckoutConfig {
7516    autocrlf: Option<String>,
7517    eol: Option<String>,
7518    symlinks: Option<bool>,
7519}
7520
7521impl CheckoutConfig {
7522    fn read(cwd: &Path) -> Result<Self> {
7523        Ok(Self {
7524            autocrlf: git_config_value(cwd, "core.autocrlf")?,
7525            eol: git_config_value(cwd, "core.eol")?,
7526            symlinks: git_config_bool(cwd, "core.symlinks")?,
7527        })
7528    }
7529
7530    fn autocrlf_is_true(&self) -> bool {
7531        self.autocrlf.as_ref().is_some_and(|value| {
7532            matches!(
7533                value.to_ascii_lowercase().as_str(),
7534                "true" | "yes" | "on" | "1"
7535            )
7536        })
7537    }
7538
7539    fn eol_is(&self, wanted: &str) -> bool {
7540        self.eol
7541            .as_ref()
7542            .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
7543    }
7544}
7545
7546fn path_has_ambiguous_transform(
7547    config: &CheckoutConfig,
7548    values: &BTreeMap<String, String>,
7549) -> Result<bool> {
7550    if path_has_external_transform(values)
7551        || attribute_is_active(values.get("ident"))
7552        || attribute_is_active(values.get("crlf"))
7553    {
7554        return Ok(true);
7555    }
7556    let text = values.get("text").map(String::as_str);
7557    let eol = values.get("eol").map(String::as_str);
7558    if text == Some("auto") {
7559        return Ok(true);
7560    }
7561    if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
7562        || !matches!(
7563            eol,
7564            Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
7565        )
7566    {
7567        return Ok(true);
7568    }
7569    if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
7570        return Ok(config.autocrlf_is_true());
7571    }
7572    Ok(false)
7573}
7574
7575fn allows_expected_crlf(
7576    config: &CheckoutConfig,
7577    values: &BTreeMap<String, String>,
7578) -> Result<bool> {
7579    if path_has_external_transform(values)
7580        || attribute_is_active(values.get("ident"))
7581        || attribute_is_active(values.get("crlf"))
7582    {
7583        return Ok(false);
7584    }
7585    let text = values.get("text").map(String::as_str);
7586    let eol = values.get("eol").map(String::as_str);
7587    if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
7588        return Ok(false);
7589    }
7590    if eol == Some("crlf") {
7591        return Ok(true);
7592    }
7593    if text != Some("set") {
7594        return Ok(false);
7595    }
7596    if let Some(autocrlf) = config.autocrlf.as_deref() {
7597        match autocrlf.to_ascii_lowercase().as_str() {
7598            "true" | "yes" | "on" | "1" => return Ok(true),
7599            "input" => return Ok(false),
7600            _ => {}
7601        }
7602    }
7603    if config.eol_is("crlf") {
7604        return Ok(true);
7605    }
7606    #[cfg(windows)]
7607    if config.eol.is_none() || config.eol_is("native") {
7608        return Ok(true);
7609    }
7610    Ok(false)
7611}
7612
7613fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
7614    let argv = git_without_automation_argv(&["config", "--get", key]);
7615    let output = proc::exec(
7616        &argv,
7617        &ExecOpts::new()
7618            .cwd(cwd)
7619            .timeout_secs(30)
7620            .check(false)
7621            .stop_descendants(true),
7622    )?;
7623    match output.code {
7624        0 => Ok(Some(output.stdout.trim().to_string())),
7625        1 => Ok(None),
7626        _ => bail!(
7627            "could not read Git configuration in {}: {}",
7628            cwd.display(),
7629            output.stderr.trim()
7630        ),
7631    }
7632}
7633
7634fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
7635    let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
7636    let output = proc::exec(
7637        &argv,
7638        &ExecOpts::new()
7639            .cwd(cwd)
7640            .timeout_secs(30)
7641            .check(false)
7642            .stop_descendants(true),
7643    )?;
7644    match output.code {
7645        0 if output.stdout.trim() == "true" => Ok(Some(true)),
7646        0 if output.stdout.trim() == "false" => Ok(Some(false)),
7647        0 => bail!(
7648            "git returned an invalid boolean for {key} in {}",
7649            cwd.display()
7650        ),
7651        1 => Ok(None),
7652        _ => bail!(
7653            "could not read Git configuration in {}: {}",
7654            cwd.display(),
7655            output.stderr.trim()
7656        ),
7657    }
7658}
7659
7660#[cfg(unix)]
7661fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
7662    use std::os::unix::fs::PermissionsExt;
7663    if metadata.permissions().mode() & 0o111 == 0 {
7664        "100644".to_string()
7665    } else {
7666        "100755".to_string()
7667    }
7668}
7669
7670#[cfg(not(unix))]
7671fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
7672    "100644".to_string()
7673}
7674
7675fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
7676    let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
7677    if !listed.is_empty() && !listed.ends_with(&[0]) {
7678        bail!(
7679            "git returned an unterminated tree listing for {}",
7680            cwd.display()
7681        );
7682    }
7683    let mut entries = Vec::new();
7684    for record in listed
7685        .split(|byte| *byte == 0)
7686        .filter(|record| !record.is_empty())
7687    {
7688        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
7689            bail!("git returned a malformed tree record for {}", cwd.display());
7690        };
7691        let fields = record[..tab]
7692            .split(|byte| *byte == b' ')
7693            .collect::<Vec<_>>();
7694        if fields.len() != 3 {
7695            bail!("git returned a malformed tree header for {}", cwd.display());
7696        }
7697        let mode = std::str::from_utf8(fields[0])
7698            .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
7699            .to_string();
7700        let oid = std::str::from_utf8(fields[2])
7701            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
7702            .to_string();
7703        entries.push(IndexEntry {
7704            path: safe_git_path(&record[tab + 1..], "tree")?,
7705            mode,
7706            oid,
7707        });
7708    }
7709    Ok(entries)
7710}
7711
7712fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
7713    Ok(tree_entries(cwd, "HEAD")?
7714        .into_iter()
7715        .filter(|entry| entry.mode == "160000")
7716        .map(|entry| (entry.path, entry.oid))
7717        .collect())
7718}
7719
7720fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
7721    let head = head_gitlinks(cwd)?;
7722    let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
7723        .into_iter()
7724        .map(|link| (link.path, link.oid))
7725        .collect();
7726    let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
7727    paths.extend(index.keys().cloned());
7728    Ok(paths
7729        .into_iter()
7730        .filter(|path| head.get(path) != index.get(path))
7731        .collect())
7732}
7733
7734fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
7735    let path = parent.join(relative);
7736    let metadata = match std::fs::symlink_metadata(&path) {
7737        Ok(metadata) => metadata,
7738        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
7739        Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7740    };
7741    if !metadata.is_dir() {
7742        bail!("the gitlink at {} is not a directory", path.display());
7743    }
7744    let canonical = std::fs::canonicalize(&path)
7745        .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
7746    if canonical != path {
7747        bail!(
7748            "the gitlink at {} resolves through a symlink",
7749            path.display()
7750        );
7751    }
7752    if !path.join(".git").exists() {
7753        let empty = std::fs::read_dir(&path)
7754            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
7755            .next()
7756            .is_none();
7757        if empty {
7758            return Ok(None);
7759        }
7760        bail!(
7761            "the uninitialized gitlink at {} contains local files",
7762            path.display()
7763        );
7764    }
7765    let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
7766    if inside.trim() != "true" {
7767        bail!("the gitlink at {} is not a worktree", path.display());
7768    }
7769    let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
7770    let top = std::fs::canonicalize(top.trim()).map_err(|e| {
7771        spar_err!(
7772            "could not resolve the gitlink top level at {}: {e}",
7773            path.display()
7774        )
7775    })?;
7776    if top != canonical {
7777        bail!(
7778            "the gitlink at {} belongs to a different worktree",
7779            path.display()
7780        );
7781    }
7782    Ok(Some(canonical))
7783}
7784
7785/// A Git entry the outer repository cannot account for.
7786///
7787/// A checkout Git itself reports as a nested repository is expected. Its
7788/// directory is recorded by identity in the untracked snapshot and keeps the
7789/// worktree from being released, so a reviewer that clones a reproduction into
7790/// ignored build output neither loses that clone nor fails the run over it.
7791/// The scan does not descend into one, because what is under it belongs to
7792/// that repository.
7793///
7794/// A Git entry Git does not report is the one worth stopping for. Running
7795/// `git init` inside a directory that already holds tracked files leaves
7796/// nothing in any listing while making every file written under it invisible
7797/// to the outer worktree, so SPAR can no longer prove what is there.
7798fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
7799    let root = std::fs::canonicalize(cwd)
7800        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7801    let mut allowed = BTreeSet::from([root.join(".git")]);
7802    let mut checkouts = BTreeSet::new();
7803    let mut repositories = vec![root.clone()];
7804    let mut visited = BTreeSet::new();
7805    while let Some(repository) = repositories.pop() {
7806        let canonical = std::fs::canonicalize(&repository)
7807            .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7808        if !visited.insert(canonical.clone()) {
7809            bail!("submodule recursion revisited {}", canonical.display());
7810        }
7811        for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
7812            let (path, nested) = untracked_record(&raw, "untracked")?;
7813            if nested {
7814                checkouts.insert(canonical.join(path));
7815            }
7816        }
7817        for link in gitlinks(&canonical)? {
7818            let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
7819                continue;
7820            };
7821            allowed.insert(submodule.join(".git"));
7822            repositories.push(submodule);
7823        }
7824    }
7825
7826    // A build deletes its own directories while this walks them. A directory
7827    // that is gone holds no Git entry, so skipping it answers the question
7828    // being asked; erroring instead ended runs whose worktrees were fine.
7829    let scan_root = root.clone();
7830    let mut directories = vec![root];
7831    while let Some(directory) = directories.pop() {
7832        let entries = match std::fs::read_dir(&directory) {
7833            Ok(entries) => entries,
7834            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7835            Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7836        };
7837        for entry in entries {
7838            let entry = match entry {
7839                Ok(entry) => entry,
7840                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7841                Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
7842            };
7843            let path = entry.path();
7844            if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
7845                continue;
7846            }
7847            if entry.file_name() == OsStr::new(".git") {
7848                if !allowed.contains(&path) {
7849                    return Ok(Some(path));
7850                }
7851                continue;
7852            }
7853            let kind = match entry.file_type() {
7854                Ok(kind) => kind,
7855                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
7856                Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
7857            };
7858            if kind.is_dir() && !checkouts.contains(&path) {
7859                directories.push(path);
7860            }
7861        }
7862    }
7863    Ok(None)
7864}
7865
7866pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
7867    if let Some(path) = unexpected_nested_git_entry(cwd)? {
7868        bail!(
7869            "the worktree contains a Git entry at {} that Git does not report as a nested \
7870             checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
7871             prove they are unchanged.",
7872            path.display()
7873        );
7874    }
7875    let root = std::fs::canonicalize(cwd)
7876        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
7877    let mut repositories = BTreeMap::new();
7878    let mut visited = BTreeSet::new();
7879    collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
7880    Ok(GitState { repositories })
7881}
7882
7883fn collect_git_state(
7884    repository: &Path,
7885    prefix: &Path,
7886    visited: &mut BTreeSet<PathBuf>,
7887    repositories: &mut BTreeMap<PathBuf, RepositoryState>,
7888) -> Result<()> {
7889    let canonical = std::fs::canonicalize(repository)
7890        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
7891    if !visited.insert(canonical.clone()) {
7892        bail!("submodule recursion revisited {}", canonical.display());
7893    }
7894    let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7895    let head = head.trim().to_string();
7896    if head.is_empty() {
7897        bail!("git returned an empty head for {}", repository.display());
7898    }
7899    let unsafe_index_flags = unsafe_index_flags(repository)?;
7900    let tracked = tracked_entries(repository)?;
7901    let gitlinks = gitlinks(repository)?;
7902    if repositories
7903        .insert(
7904            prefix.to_path_buf(),
7905            RepositoryState {
7906                head,
7907                unsafe_index_flags,
7908                tracked,
7909                gitlinks: gitlinks
7910                    .iter()
7911                    .map(|link| (link.path.clone(), link.oid.clone()))
7912                    .collect(),
7913            },
7914        )
7915        .is_some()
7916    {
7917        bail!("Git state contains duplicate repository path {:?}", prefix);
7918    }
7919
7920    for link in gitlinks {
7921        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
7922            continue;
7923        };
7924        collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
7925    }
7926    Ok(())
7927}
7928
7929fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
7930    let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
7931    if !listed.is_empty() && !listed.ends_with(&[0]) {
7932        bail!(
7933            "git returned an unterminated index-flag listing for {}",
7934            cwd.display()
7935        );
7936    }
7937    let mut unsafe_records = Vec::new();
7938    for record in listed
7939        .split(|byte| *byte == 0)
7940        .filter(|record| !record.is_empty())
7941    {
7942        if record.len() < 3 || record[1] != b' ' {
7943            bail!(
7944                "git returned a malformed index-flag record for {}",
7945                cwd.display()
7946            );
7947        }
7948        if record[0] != b'H' {
7949            unsafe_records.extend_from_slice(record);
7950            unsafe_records.push(0);
7951        }
7952    }
7953    Ok(unsafe_records)
7954}
7955
7956pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
7957    safe_git_state(cwd).map(|_| ())
7958}
7959
7960pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
7961    let state = git_state(cwd)?;
7962    if let Some((path, _repository)) = state
7963        .repositories
7964        .iter()
7965        .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
7966    {
7967        let label = if path.as_os_str().is_empty() {
7968            cwd.to_path_buf()
7969        } else {
7970            cwd.join(path)
7971        };
7972        bail!(
7973            "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
7974             SPAR cannot prove the working files are unchanged, so it was kept.",
7975            label.display()
7976        );
7977    }
7978    Ok(state)
7979}
7980
7981fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
7982    repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
7983}
7984
7985fn repository_has_recoverable_work_with(
7986    cwd: &Path,
7987    include_ignored: bool,
7988    disposable: Disposable,
7989) -> Result<bool> {
7990    if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
7991        return Ok(true);
7992    }
7993    let mut visited = BTreeSet::new();
7994    repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
7995}
7996
7997fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
7998    let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
7999    let git_dir = PathBuf::from(git_dir.trim());
8000    let git_dir = if git_dir.is_absolute() {
8001        git_dir
8002    } else {
8003        cwd.join(git_dir)
8004    };
8005    let git_dir = std::fs::canonicalize(&git_dir)
8006        .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
8007    match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
8008        Ok(_) => return Ok(true),
8009        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8010        Err(error) => {
8011            return Err(spar_err!(
8012                "could not inspect per-worktree configuration in {}: {error}",
8013                git_dir.display()
8014            ))
8015        }
8016    }
8017
8018    let orig_head = git_dir.join("ORIG_HEAD");
8019    match std::fs::symlink_metadata(&orig_head) {
8020        Ok(metadata) if metadata.is_file() => {
8021            let oid = std::fs::read_to_string(&orig_head)
8022                .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
8023            let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
8024                return Ok(true);
8025            };
8026            if !commit_has_shared_ref(cwd, &commit)? {
8027                return Ok(true);
8028            }
8029        }
8030        Ok(_) => return Ok(true),
8031        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8032        Err(error) => {
8033            return Err(spar_err!(
8034                "could not inspect {}: {error}",
8035                orig_head.display()
8036            ))
8037        }
8038    }
8039
8040    let edit_message = git_dir.join("COMMIT_EDITMSG");
8041    match std::fs::symlink_metadata(&edit_message) {
8042        Ok(metadata) if metadata.is_file() => {
8043            let draft = std::fs::read(&edit_message)
8044                .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
8045            if draft != head_commit_message(cwd)? {
8046                return Ok(true);
8047            }
8048        }
8049        Ok(_) => return Ok(true),
8050        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8051        Err(error) => {
8052            return Err(spar_err!(
8053                "could not inspect {}: {error}",
8054                edit_message.display()
8055            ))
8056        }
8057    }
8058
8059    if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
8060        return Ok(true);
8061    }
8062
8063    let local_refs = run_git_bytes(
8064        cwd,
8065        &[
8066            "for-each-ref",
8067            "--format=%(refname)",
8068            "refs/worktree",
8069            "refs/bisect",
8070            "refs/rewritten",
8071        ],
8072    )?;
8073    if !local_refs.is_empty() {
8074        return Ok(true);
8075    }
8076
8077    // `FETCH_HEAD` only names what a `git fetch` in this worktree brought
8078    // down. The objects live in the shared store and the next fetch rewrites
8079    // it, so an agent that fetched once must not keep the checkout forever.
8080    for entry in std::fs::read_dir(&git_dir)
8081        .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
8082    {
8083        let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
8084        let known = matches!(
8085            entry.file_name().to_str(),
8086            Some(
8087                "HEAD"
8088                    | "ORIG_HEAD"
8089                    | "COMMIT_EDITMSG"
8090                    | "FETCH_HEAD"
8091                    | "commondir"
8092                    | "gitdir"
8093                    | "index"
8094                    | "logs"
8095                    | "refs"
8096            )
8097        );
8098        if !known {
8099            return Ok(true);
8100        }
8101    }
8102
8103    let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8104    if !commit_has_shared_ref(cwd, head.trim())? {
8105        return Ok(true);
8106    }
8107    Ok(false)
8108}
8109
8110fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
8111    let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
8112    let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
8113        bail!(
8114            "git returned a commit without a message separator in {}",
8115            cwd.display()
8116        );
8117    };
8118    Ok(commit[split + 2..].to_vec())
8119}
8120
8121fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
8122    let metadata = match std::fs::symlink_metadata(logs) {
8123        Ok(metadata) => metadata,
8124        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
8125        Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
8126    };
8127    if !metadata.is_dir() {
8128        return Ok(true);
8129    }
8130    let mut files = Vec::new();
8131    let mut directories = vec![logs.to_path_buf()];
8132    while let Some(directory) = directories.pop() {
8133        for entry in std::fs::read_dir(&directory)
8134            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
8135        {
8136            let entry =
8137                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
8138            let path = entry.path();
8139            let kind = entry
8140                .file_type()
8141                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
8142            if kind.is_dir() {
8143                directories.push(path);
8144            } else if kind.is_file() {
8145                files.push(path);
8146            } else {
8147                return Ok(true);
8148            }
8149        }
8150    }
8151
8152    let mut commits = BTreeSet::new();
8153    for path in files {
8154        if !collect_reflog_commits(cwd, &path, &mut commits)? {
8155            return Ok(true);
8156        }
8157    }
8158    for commit in commits {
8159        if !commit_has_shared_ref(cwd, &commit)? {
8160            return Ok(true);
8161        }
8162    }
8163    Ok(false)
8164}
8165
8166/// Every commit named by one ref's common reflog must survive deletion of that
8167/// ref. Ancestors of a durable current tip survive with the tip; divergent
8168/// entries need another shared ref of their own.
8169fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
8170    let common = common_git_dir(cwd)?;
8171    let reflog = common.join("logs").join(refname);
8172    let metadata = match std::fs::symlink_metadata(&reflog) {
8173        Ok(metadata) => metadata,
8174        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
8175        Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
8176    };
8177    if !metadata.is_file() {
8178        return Ok(false);
8179    }
8180    let mut commits = BTreeSet::new();
8181    if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
8182        return Ok(false);
8183    }
8184    for commit in commits {
8185        if is_ancestor(cwd, &commit, durable_tip)?
8186            || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
8187        {
8188            continue;
8189        }
8190        return Ok(false);
8191    }
8192    Ok(true)
8193}
8194
8195fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
8196    let file = std::fs::File::open(path)
8197        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8198    for line in std::io::BufReader::new(file).lines() {
8199        let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
8200        let mut fields = line.splitn(3, ' ');
8201        let Some(old) = fields.next() else {
8202            return Ok(false);
8203        };
8204        let Some(new) = fields.next() else {
8205            return Ok(false);
8206        };
8207        if fields.next().is_none() {
8208            return Ok(false);
8209        }
8210        for oid in [old, new] {
8211            if oid.bytes().all(|byte| byte == b'0') {
8212                continue;
8213            }
8214            let Some(commit) = resolve_optional_commit(cwd, oid)? else {
8215                return Ok(false);
8216            };
8217            commits.insert(commit);
8218        }
8219    }
8220    Ok(true)
8221}
8222
8223fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
8224    let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
8225    let path = PathBuf::from(raw.trim());
8226    let path = if path.is_absolute() {
8227        path
8228    } else {
8229        cwd.join(path)
8230    };
8231    std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
8232}
8233
8234fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
8235    let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
8236    let output = proc::exec(
8237        &argv,
8238        &ExecOpts::new()
8239            .cwd(cwd)
8240            .timeout_secs(30)
8241            .check(false)
8242            .stop_descendants(true),
8243    )?;
8244    match output.code {
8245        0 => Ok(true),
8246        1 => Ok(false),
8247        _ => bail!("{}", proc::failure_message(&argv, &output)),
8248    }
8249}
8250
8251fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
8252    let commit = format!("{oid}^{{commit}}");
8253    let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
8254    let output = proc::exec(
8255        &argv,
8256        &ExecOpts::new()
8257            .cwd(cwd)
8258            .timeout_secs(30)
8259            .check(false)
8260            .stop_descendants(true),
8261    )?;
8262    if output.code != 0 {
8263        return Ok(None);
8264    }
8265    let oid = output.stdout.trim();
8266    if oid.is_empty() {
8267        return Ok(None);
8268    }
8269    Ok(Some(oid.to_string()))
8270}
8271
8272fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
8273    commit_has_shared_ref_except(cwd, oid, None)
8274}
8275
8276fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
8277    let contains = format!("--contains={oid}");
8278    let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
8279    Ok(shared.split(|byte| *byte == b'\n').any(|record| {
8280        !record.is_empty()
8281            && !record.starts_with(b"refs/worktree/")
8282            && !record.starts_with(b"refs/bisect/")
8283            && !record.starts_with(b"refs/rewritten/")
8284            && exclude.is_none_or(|excluded| record != excluded.as_bytes())
8285    }))
8286}
8287
8288/// Whether removing the worktree would take away an untracked file somebody
8289/// might want back.
8290///
8291/// Ordinary untracked files always count. So does an ignored file outside the
8292/// known build and cache directories, because an ignored path is only a path
8293/// Git was told not to track, which is where a local `.env` lives as readily as
8294/// compiler output.
8295///
8296/// Recognized build and cache output does not. A managed commit already leaves
8297/// it out rather than treating it as work, and the command that wrote it writes
8298/// it again. Counting it kept every worktree whose tests or build had run,
8299/// which is nearly all of them, so a merged pull request still left its
8300/// checkout behind. A repository nested in that output is somebody else's
8301/// history and counts whatever it sits under.
8302fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
8303    let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
8304    for raw in &ordinary {
8305        let (path, _) = untracked_record(raw, "untracked")?;
8306        if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
8307            continue;
8308        }
8309        return Ok(true);
8310    }
8311    let listed = untracked_listing(
8312        cwd,
8313        &[
8314            "ls-files",
8315            "--others",
8316            "--ignored",
8317            "--exclude-standard",
8318            "-z",
8319        ],
8320    )?;
8321    for raw in listed {
8322        let (path, nested) = untracked_record(&raw, "ignored")?;
8323        // A repository cloned into the output keeps the checkout whatever the
8324        // mode: its objects exist nowhere else, which is the one thing a
8325        // rebuild from origin cannot replace.
8326        if nested {
8327            return Ok(true);
8328        }
8329        if disposable == Disposable::BuildOutput
8330            && !is_generated_artifact(&path)
8331            && !is_desktop_metadata(&path)
8332        {
8333            return Ok(true);
8334        }
8335    }
8336    Ok(false)
8337}
8338
8339fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
8340    let listed = run_git_bytes(cwd, args)?;
8341    if !listed.is_empty() && !listed.ends_with(&[0]) {
8342        bail!(
8343            "git returned an unterminated untracked-file list for {}",
8344            cwd.display()
8345        );
8346    }
8347    Ok(listed
8348        .split(|byte| *byte == 0)
8349        .filter(|raw| !raw.is_empty())
8350        .map(|raw| raw.to_vec())
8351        .collect())
8352}
8353
8354fn repository_has_recoverable_work_inner(
8355    cwd: &Path,
8356    include_ignored: bool,
8357    disposable: Disposable,
8358    visited: &mut BTreeSet<PathBuf>,
8359) -> Result<bool> {
8360    let canonical = std::fs::canonicalize(cwd)
8361        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
8362    if !visited.insert(canonical.clone()) {
8363        bail!("submodule recursion revisited {}", canonical.display());
8364    }
8365    if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
8366        return Ok(true);
8367    }
8368    if !unsafe_index_flags(cwd)?.is_empty() {
8369        return Ok(true);
8370    }
8371    if attributes_may_be_modified(cwd)? {
8372        return Ok(true);
8373    }
8374    if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
8375        return Ok(true);
8376    }
8377    if include_ignored {
8378        let index = index_entries(cwd)?
8379            .into_iter()
8380            .map(|entry| (entry.path, (entry.mode, entry.oid)))
8381            .collect::<BTreeMap<_, _>>();
8382        let head = tree_entries(cwd, "HEAD")?
8383            .into_iter()
8384            .map(|entry| (entry.path, (entry.mode, entry.oid)))
8385            .collect::<BTreeMap<_, _>>();
8386        if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
8387            return Ok(true);
8388        }
8389        let tracked = tracked_entries(cwd)?;
8390        let effective = check_attributes(cwd, tracked.keys().cloned())?;
8391        let config = CheckoutConfig::read(cwd)?;
8392        for (path, entry) in tracked {
8393            let Some(worktree) = entry.worktree else {
8394                return Ok(true);
8395            };
8396            let attributes = effective.get(&path).ok_or_else(|| {
8397                spar_err!("git omitted attributes for {}", cwd.join(&path).display())
8398            })?;
8399            if path_has_ambiguous_transform(&config, attributes)? {
8400                return Ok(true);
8401            }
8402            let symlink_file = entry.index_mode == "120000"
8403                && worktree.mode == "100644"
8404                && worktree.raw_oid == entry.index_oid
8405                && config.symlinks == Some(false);
8406            if worktree.mode != entry.index_mode && !symlink_file {
8407                return Ok(true);
8408            }
8409            if entry.index_mode == "120000" {
8410                if worktree.raw_oid != entry.index_oid {
8411                    return Ok(true);
8412                }
8413                continue;
8414            }
8415            #[cfg(unix)]
8416            {
8417                let expected = if entry.index_mode == "100755" {
8418                    0o755
8419                } else {
8420                    0o644
8421                };
8422                if worktree.permissions != expected {
8423                    return Ok(true);
8424                }
8425            }
8426            if allows_expected_crlf(&config, attributes)? {
8427                let (normalized, every_lf_was_crlf) =
8428                    normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
8429                if !every_lf_was_crlf || normalized != entry.index_oid {
8430                    return Ok(true);
8431                }
8432            } else if worktree.raw_oid != entry.index_oid {
8433                return Ok(true);
8434            }
8435        }
8436    } else {
8437        let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
8438        if !run_git_bytes(cwd, &args)?.is_empty() {
8439            return Ok(true);
8440        }
8441    }
8442    for link in gitlinks(cwd)? {
8443        let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
8444            continue;
8445        };
8446        // Git stores a linked worktree's initialized submodule objects under
8447        // that worktree's administrative directory. Ordinary removal cannot
8448        // prove a local submodule commit exists anywhere else, even when both
8449        // working trees look clean.
8450        if include_ignored {
8451            return Ok(true);
8452        }
8453        let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
8454        if head.trim() != link.oid {
8455            return Ok(true);
8456        }
8457        if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
8458        {
8459            return Ok(true);
8460        }
8461    }
8462    Ok(false)
8463}
8464
8465pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
8466    repository_has_recoverable_work(cwd, false)
8467}
8468
8469fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
8470    let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
8471    Ok(!run_git_bytes(cwd, &args)?.is_empty())
8472}
8473
8474#[cfg(unix)]
8475fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8476    use std::os::unix::ffi::OsStringExt;
8477    Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
8478}
8479
8480#[cfg(not(unix))]
8481fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
8482    String::from_utf8(raw.to_vec())
8483        .map(PathBuf::from)
8484        .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
8485}
8486
8487/// Fingerprint the directory of a nested repository without reading inside it.
8488///
8489/// The files under it are that repository's, not this one's. They are recorded
8490/// against its own baseline whenever SPAR works there, and a run of its own may
8491/// legitimately add or remove entries while this call is in flight, so the
8492/// volatile directory fields stay out of the fingerprint. Identity and type
8493/// remain, which is what makes deleting the checkout, or replacing it with a
8494/// file, observable from the outer worktree.
8495/// What a path Git listed looks like once it is no longer there.
8496///
8497/// A reviewer building the project deletes and rewrites its output constantly,
8498/// so a path can go between the listing that named it and the stat that
8499/// measures it. Treating that as an unreadable worktree ended runs that had
8500/// nothing wrong with them. The record stays in the snapshot rather than being
8501/// dropped, so a path that vanishes cannot pass for one that was never there.
8502fn vanished_untracked_file() -> UntrackedFile {
8503    UntrackedFile {
8504        kind: 0,
8505        len: 0,
8506        modified: None,
8507        created: None,
8508        readonly: false,
8509        symlink_target: None,
8510        #[cfg(unix)]
8511        device: 0,
8512        #[cfg(unix)]
8513        inode: 0,
8514        #[cfg(unix)]
8515        mode: 0,
8516        #[cfg(unix)]
8517        change_seconds: 0,
8518        #[cfg(unix)]
8519        change_nanoseconds: 0,
8520    }
8521}
8522
8523fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
8524    let metadata = match std::fs::symlink_metadata(path) {
8525        Ok(metadata) => metadata,
8526        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8527        Err(e) => {
8528            return Err(spar_err!(
8529                "could not inspect the nested repository at {}: {e}",
8530                path.display()
8531            ))
8532        }
8533    };
8534    if !metadata.is_dir() {
8535        bail!(
8536            "git reported {} as a nested repository, but it is not a directory",
8537            path.display()
8538        );
8539    }
8540    if std::fs::symlink_metadata(path.join(".git")).is_err() {
8541        bail!(
8542            "git reported {} as a nested repository, but it has no Git entry",
8543            path.display()
8544        );
8545    }
8546    #[cfg(unix)]
8547    {
8548        use std::os::unix::fs::MetadataExt;
8549        Ok(UntrackedFile {
8550            kind: 3,
8551            len: 0,
8552            modified: None,
8553            created: metadata.created().ok(),
8554            readonly: metadata.permissions().readonly(),
8555            symlink_target: None,
8556            device: metadata.dev(),
8557            inode: metadata.ino(),
8558            mode: metadata.mode(),
8559            change_seconds: 0,
8560            change_nanoseconds: 0,
8561        })
8562    }
8563    #[cfg(not(unix))]
8564    {
8565        Ok(UntrackedFile {
8566            kind: 3,
8567            len: 0,
8568            modified: None,
8569            created: metadata.created().ok(),
8570            readonly: metadata.permissions().readonly(),
8571            symlink_target: None,
8572        })
8573    }
8574}
8575
8576fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
8577    let metadata = match std::fs::symlink_metadata(path) {
8578        Ok(metadata) => metadata,
8579        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
8580        Err(e) => {
8581            return Err(spar_err!(
8582                "could not inspect untracked file {}: {e}",
8583                path.display()
8584            ))
8585        }
8586    };
8587    let kind = if metadata.file_type().is_symlink() {
8588        2
8589    } else if metadata.is_file() {
8590        1
8591    } else {
8592        bail!(
8593            "untracked path {} is not a regular file or symlink",
8594            path.display()
8595        );
8596    };
8597    let symlink_target = if kind == 2 {
8598        match std::fs::read_link(path) {
8599            Ok(target) => Some(os_str_bytes(target.as_os_str())?),
8600            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
8601                return Ok(vanished_untracked_file())
8602            }
8603            Err(e) => {
8604                return Err(spar_err!(
8605                    "could not read untracked symlink {}: {e}",
8606                    path.display()
8607                ))
8608            }
8609        }
8610    } else {
8611        None
8612    };
8613    #[cfg(unix)]
8614    {
8615        use std::os::unix::fs::MetadataExt;
8616        Ok(UntrackedFile {
8617            kind,
8618            len: metadata.len(),
8619            modified: metadata.modified().ok(),
8620            created: metadata.created().ok(),
8621            readonly: metadata.permissions().readonly(),
8622            symlink_target,
8623            device: metadata.dev(),
8624            inode: metadata.ino(),
8625            mode: metadata.mode(),
8626            change_seconds: metadata.ctime(),
8627            change_nanoseconds: metadata.ctime_nsec(),
8628        })
8629    }
8630    #[cfg(not(unix))]
8631    {
8632        Ok(UntrackedFile {
8633            kind,
8634            len: metadata.len(),
8635            modified: metadata.modified().ok(),
8636            created: metadata.created().ok(),
8637            readonly: metadata.permissions().readonly(),
8638            symlink_target,
8639        })
8640    }
8641}
8642
8643#[cfg(unix)]
8644fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8645    use std::os::unix::ffi::OsStrExt;
8646    Ok(value.as_bytes().to_vec())
8647}
8648
8649#[cfg(not(unix))]
8650fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
8651    value
8652        .to_str()
8653        .map(|value| value.as_bytes().to_vec())
8654        .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
8655}
8656
8657#[cfg(unix)]
8658fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8659    use std::os::unix::fs::MetadataExt;
8660    right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
8661}
8662
8663#[cfg(not(unix))]
8664fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
8665    right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
8666}
8667
8668#[derive(Debug, Clone, serde::Serialize, Deserialize)]
8669pub struct BranchRecord {
8670    pub kind: String,
8671    pub number: i64,
8672}
8673
8674/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
8675/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
8676pub fn review_ref(number: i64) -> String {
8677    format!("refs/spar/pr-{number}")
8678}
8679
8680pub fn is_finished(state: &str) -> bool {
8681    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
8682}
8683
8684/// Write text through a temporary file and rename, so a kill cannot leave a
8685/// truncated file behind.
8686///
8687/// The follow-up queue is the one file spar rewrites in place rather than
8688/// appends to, and a truncated queue is lost work: what it held was never
8689/// written anywhere else.
8690pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
8691    if let Some(parent) = path.parent() {
8692        std::fs::create_dir_all(parent)
8693            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
8694    }
8695    // The extension defaults to `json` so `clear_state`, which removes a
8696    // leftover `pr-N.json.tmp` by name, keeps finding the one this wrote.
8697    let tmp = path.with_extension(format!(
8698        "{}.tmp",
8699        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
8700    ));
8701    std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
8702    std::fs::rename(&tmp, path)
8703        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
8704    Ok(())
8705}
8706
8707/// Whether a follow-up file already has an entry under this exact title.
8708///
8709/// Whole heading lines, case insensitively. A substring check answered yes
8710/// for "Retry loop" against "## Retry loop never terminates", which is a
8711/// different defect, and the new one was dropped with a line saying it was
8712/// already noted.
8713pub fn has_heading(text: &str, title: &str) -> bool {
8714    let wanted = title.trim().to_lowercase();
8715    if wanted.is_empty() {
8716        return false;
8717    }
8718    text.lines()
8719        .filter_map(|line| line.trim().strip_prefix("## "))
8720        .any(|heading| heading.trim().to_lowercase() == wanted)
8721}
8722
8723/// Write JSON through a temporary file and rename, so a kill cannot leave a
8724/// truncated state file behind.
8725pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
8726    write_text_atomic(path, &serde_json::to_string_pretty(value)?)
8727}
8728
8729/// One row of `gh pr list --json number,url,title,baseRefName,closingIssuesReferences`.
8730#[derive(Deserialize)]
8731#[serde(rename_all = "camelCase")]
8732pub struct LinkedPrRow {
8733    number: i64,
8734    #[serde(default)]
8735    url: String,
8736    #[serde(default)]
8737    title: String,
8738    #[serde(default)]
8739    closing_issues_references: Vec<IssueRef>,
8740}
8741
8742/// The listed pull requests, or `None` when the payload could not be read.
8743///
8744/// Separated from the gh call so the real payload shape can be tested, and kept
8745/// distinct from an empty list, because silently failing to parse would look
8746/// exactly like "no pull request exists", which is the answer that makes spar
8747/// implement over the top of somebody's work.
8748pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
8749    serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
8750}
8751
8752/// Among the listed pull requests, the first that would close `issue`.
8753pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
8754    rows.iter()
8755        .find(|row| {
8756            row.closing_issues_references
8757                .iter()
8758                .any(|linked| linked.number == issue)
8759        })
8760        .map(|row| PrRef {
8761            number: row.number,
8762            url: row.url.clone(),
8763            title: row.title.clone(),
8764        })
8765}
8766
8767/// Both halves together, for the tests that hold the real payload.
8768pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
8769    pick_linked_pr(&linked_pr_rows(json)?, issue)
8770}
8771
8772/// Flatten whatever `gh api --paginate` printed into a list of comments.
8773///
8774/// Current gh merges array pages into one array. Older builds concatenated one
8775/// document per page. A streaming parser reads either, and unlike splitting the
8776/// text on a bracket pair it cannot be fooled by a comment body that happens to
8777/// contain one, which would otherwise make a resume silently start over.
8778pub(crate) fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
8779    if text.trim().is_empty() {
8780        return Err(spar_err!("GitHub returned no comment data"));
8781    }
8782    let mut out = Vec::new();
8783    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8784        match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
8785            Value::Array(items) => out.extend(items),
8786            _ => return Err(spar_err!("unexpected non-array comment page")),
8787        }
8788    }
8789    Ok(out)
8790}
8791
8792pub fn parse_comment_pages(text: &str) -> Vec<Value> {
8793    let mut out = Vec::new();
8794    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
8795        match value {
8796            Ok(Value::Array(items)) => out.extend(items),
8797            Ok(other) => out.push(other),
8798            Err(_) => break,
8799        }
8800    }
8801    out
8802}
8803
8804/// Whether a comment is a state block spar itself wrote.
8805///
8806/// The body has to open with the marker, so a person quoting it in a sentence
8807/// is not one; it has to parse, so a half-quoted block is not one; and it has
8808/// to be the viewer's own comment, the way `comments.rs` already tells spar's
8809/// writes apart. Anything less and `write_pr_state` patches somebody's comment
8810/// with JSON, and `spar clean --pr-state` later deletes it.
8811pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
8812    body.trim_start().starts_with(STATE_MARKER)
8813        && !viewer.trim().is_empty()
8814        && author.trim().eq_ignore_ascii_case(viewer.trim())
8815        && parse_state_comment(body).is_some()
8816}
8817
8818/// Extract the payload from a state comment. The marker is followed by JSON and
8819/// terminated with `-->`.
8820pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
8821    let marker = body.find(STATE_MARKER)?;
8822    let start = body[marker..].find('{')? + marker;
8823    let end = body.rfind('}')?;
8824    if end <= start {
8825        return None;
8826    }
8827    match serde_json::from_str(&body[start..=end]) {
8828        Ok(state) => Some(state),
8829        Err(_) => {
8830            logdim!("found a spar state comment but could not parse it");
8831            None
8832        }
8833    }
8834}
8835
8836fn choose_state_for_head(
8837    candidates: Vec<PersistedState>,
8838    actual_head: &str,
8839) -> Option<PersistedState> {
8840    let matching: Vec<PersistedState> = candidates
8841        .iter()
8842        .filter(|state| state.pr_head == actual_head)
8843        .cloned()
8844        .collect();
8845    if !matching.is_empty() {
8846        return newest_state(matching);
8847    }
8848    newest_state(candidates)
8849}
8850
8851fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
8852    candidates.into_iter().reduce(|best, candidate| {
8853        if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
8854            candidate
8855        } else {
8856            // The local candidate is supplied first. Keeping the first exact
8857            // tie recovers correctly from a local write followed by a failed
8858            // pull request state update, including legacy states with no
8859            // checkpoint field.
8860            best
8861        }
8862    })
8863}
8864
8865/// Where this binary lives, so `git filter-branch` can call back into it.
8866///
8867/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
8868/// tests, whose `current_exe` is the test harness rather than spar, and for
8869/// anyone who ships spar behind a wrapper script.
8870pub fn self_binary() -> Result<PathBuf> {
8871    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
8872        let path = PathBuf::from(path);
8873        if proc::is_executable(&path) {
8874            return Ok(path);
8875        }
8876        bail!(
8877            "SPAR_SELF_BIN is set to {}, which is not executable",
8878            path.display()
8879        );
8880    }
8881    std::env::current_exe()
8882        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
8883}
8884
8885fn bool_env(value: bool) -> &'static str {
8886    if value {
8887        "1"
8888    } else {
8889        "0"
8890    }
8891}
8892
8893/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
8894/// shell command, and an install path with a space in it is not exotic.
8895pub fn sh_quote(text: &str) -> String {
8896    format!("'{}'", text.replace('\'', r"'\''"))
8897}
8898
8899/// Style rules for the `scrub-filter` subcommand, which runs in a child process
8900/// spawned by git and so cannot see the parent's config.
8901pub fn style_from_env() -> Style {
8902    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
8903    Style {
8904        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
8905        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
8906        ..Style::permissive()
8907    }
8908}
8909
8910#[cfg(test)]
8911mod tests {
8912    use super::*;
8913    use crate::config::StateStore;
8914    use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
8915    use std::process::Command;
8916
8917    fn repo_for_titles() -> Repo {
8918        Repo {
8919            root: PathBuf::from("/nonexistent"),
8920            style: Style::default(),
8921            branch_prefix: String::new(),
8922            state_store: StateStore::Local,
8923            followups: crate::config::Followups::Issues,
8924            drafts: Drafts::Never,
8925            fork_when_denied: true,
8926            setup: CheckCommand::default(),
8927            fork: OnceLock::new(),
8928            viewer: OnceLock::new(),
8929            checkpoints: Mutex::new(BTreeMap::new()),
8930            writes: WriteStats::default(),
8931        }
8932    }
8933
8934    #[test]
8935    fn write_results_accumulate_for_the_run() {
8936        let repo = repo_for_titles();
8937
8938        let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
8939        let _: std::result::Result<(), ()> = repo.record_write(Err(()));
8940
8941        assert_eq!(
8942            WriteSummary {
8943                attempted: 2,
8944                failed: 1,
8945            },
8946            repo.write_summary()
8947        );
8948    }
8949
8950    #[test]
8951    fn only_failed_write_preflights_join_the_summary() {
8952        let repo = repo_for_titles();
8953
8954        let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
8955        let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
8956
8957        assert_eq!(
8958            WriteSummary {
8959                attempted: 1,
8960                failed: 1,
8961            },
8962            repo.write_summary()
8963        );
8964    }
8965
8966    #[test]
8967    fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
8968        let repo = repo_for_titles();
8969
8970        assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
8971        assert_eq!(
8972            WriteSummary {
8973                attempted: 1,
8974                failed: 1,
8975            },
8976            repo.write_summary()
8977        );
8978    }
8979
8980    #[test]
8981    fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
8982        let mut repo = repo_for_titles();
8983        repo.followups = Followups::Local;
8984
8985        assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
8986        assert_eq!(WriteSummary::default(), repo.write_summary());
8987    }
8988
8989    #[test]
8990    fn a_failed_remote_state_read_stops_before_state_mutation() {
8991        let root = std::env::temp_dir().join(format!(
8992            "spar-state-preflight-{}-{}",
8993            std::process::id(),
8994            std::time::SystemTime::now()
8995                .duration_since(std::time::UNIX_EPOCH)
8996                .unwrap()
8997                .as_nanos()
8998        ));
8999        std::fs::create_dir_all(&root).unwrap();
9000        let _fixture = ReviewFixture { root: root.clone() };
9001        let mut repo = repo_for_titles();
9002        repo.root = root;
9003        repo.state_store = StateStore::Both;
9004        let state = PersistedState {
9005            version: 1,
9006            checkpoint: 4,
9007            round: 2,
9008            next_actor: "a".into(),
9009            status: Status::Pending,
9010            pr_head: "abc123".into(),
9011            ledger: Ledger::new(),
9012            filed: Vec::new(),
9013            open_findings: Vec::new(),
9014            disputes: Vec::new(),
9015            noted: Vec::new(),
9016        };
9017
9018        let error = repo
9019            .write_state_after_remote_read(
9020                7,
9021                &state,
9022                Err(crate::error::SparError::new("state comments unavailable")),
9023            )
9024            .unwrap_err();
9025
9026        assert!(error.to_string().contains("state comments unavailable"));
9027        assert!(!repo.state_path(7).exists());
9028        assert_eq!(0, repo.remembered_checkpoint(7));
9029        assert_eq!(
9030            WriteSummary {
9031                attempted: 1,
9032                failed: 1,
9033            },
9034            repo.write_summary()
9035        );
9036    }
9037
9038    #[test]
9039    fn only_known_build_and_cache_directories_are_generated_artifacts() {
9040        assert!(is_generated_artifact(Path::new("target/debug/artifact")));
9041        assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
9042        assert!(is_generated_artifact(Path::new(
9043            "package/node_modules/dependency/file.js"
9044        )));
9045        assert!(!is_generated_artifact(Path::new(
9046            "distribution/required-package.js"
9047        )));
9048        assert!(!is_generated_artifact(Path::new(
9049            "generated/required-fixture.txt"
9050        )));
9051        assert!(!is_generated_artifact(Path::new("local.env")));
9052    }
9053
9054    #[test]
9055    fn only_finder_state_files_are_desktop_metadata() {
9056        assert!(is_desktop_metadata(Path::new(".DS_Store")));
9057        assert!(is_desktop_metadata(Path::new("src/tests/.DS_Store")));
9058        assert!(!is_desktop_metadata(Path::new(".DS_Store.bak")));
9059        assert!(!is_desktop_metadata(Path::new(".DS_Store/notes.md")));
9060    }
9061
9062    struct ReviewFixture {
9063        root: PathBuf,
9064    }
9065
9066    impl Drop for ReviewFixture {
9067        fn drop(&mut self) {
9068            let _ = std::fs::remove_dir_all(&self.root);
9069        }
9070    }
9071
9072    fn test_git(cwd: &Path, args: &[&str]) -> String {
9073        let output = Command::new("git")
9074            .args(args)
9075            .current_dir(cwd)
9076            .output()
9077            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
9078        assert!(
9079            output.status.success(),
9080            "git {args:?} failed: {}",
9081            String::from_utf8_lossy(&output.stderr)
9082        );
9083        String::from_utf8_lossy(&output.stdout).into_owned()
9084    }
9085
9086    /// The diff a line comment is checked against is parsed from git's output,
9087    /// so a user setting that changes that output must not change the answer,
9088    /// and a text converter the branch could have changed must never run.
9089    #[test]
9090    fn the_lines_a_comment_can_go_on_ignore_how_the_user_has_git_print_a_diff() {
9091        let root =
9092            std::env::temp_dir().join(format!("spar-repo-test-hunks-{}", std::process::id()));
9093        let _cleanup = ReviewFixture { root: root.clone() };
9094        let origin = root.join("origin.git");
9095        let work = root.join("work");
9096        let marker = root.join("textconv-ran");
9097        std::fs::create_dir_all(&origin).unwrap();
9098        std::fs::create_dir_all(&work).unwrap();
9099        test_git(&origin, &["init", "--bare", "-b", "main"]);
9100        test_git(&work, &["init", "-b", "main"]);
9101        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9102        test_git(&work, &["config", "user.name", "spar test"]);
9103        test_git(&work, &["config", "commit.gpgsign", "false"]);
9104        test_git(&work, &["config", "diff.noprefix", "true"]);
9105        test_git(&work, &["config", "diff.context", "0"]);
9106        test_git(&work, &["config", "diff.interHunkContext", "10"]);
9107        test_git(&work, &["config", "diff.algorithm", "patience"]);
9108        test_git(&work, &["config", "color.diff", "always"]);
9109        test_git(
9110            &work,
9111            &[
9112                "config",
9113                "diff.conv.textconv",
9114                &format!("touch {}; cat", marker.display()),
9115            ],
9116        );
9117        std::fs::write(work.join(".git/info/attributes"), "*.txt diff=conv\n").unwrap();
9118        let lines: String = (1..=20).map(|n| format!("line {n}\n")).collect();
9119        std::fs::write(work.join("src.txt"), &lines).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(&work, &["checkout", "-b", "feature"]);
9128        std::fs::write(
9129            work.join("src.txt"),
9130            lines
9131                .replace("line 5\n", "changed 5\n")
9132                .replace("line 15\n", "changed 15\n"),
9133        )
9134        .unwrap();
9135        std::fs::write(work.join("new.txt"), "a\nb\n").unwrap();
9136        std::fs::write(work.join("with space.txt"), "a\n").unwrap();
9137        test_git(&work, &["add", "."]);
9138        test_git(&work, &["commit", "-m", "change"]);
9139        let cfg = crate::config::parse(
9140            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9141        )
9142        .unwrap();
9143        let repo = Repo::open(&work, &cfg).unwrap();
9144        assert_eq!(
9145            Hunks::from([
9146                ("new.txt".to_string(), vec![(1, 2)]),
9147                ("src.txt".to_string(), vec![(2, 8), (12, 18)]),
9148                ("with space.txt".to_string(), vec![(1, 1)]),
9149            ]),
9150            repo.diff_hunks(&work, "main")
9151        );
9152        assert!(!marker.exists(), "the text converter ran");
9153        // The converter is real: a plain diff with the same settings runs it.
9154        test_git(&work, &["diff", "origin/main...HEAD"]);
9155        assert!(marker.exists(), "the fixture's converter never runs at all");
9156    }
9157
9158    fn review_fixture(
9159        tag: &str,
9160        number: i64,
9161    ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
9162        use std::sync::atomic::{AtomicU32, Ordering};
9163        static NEXT: AtomicU32 = AtomicU32::new(0);
9164        let id = NEXT.fetch_add(1, Ordering::Relaxed);
9165        let root =
9166            std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
9167        let origin = root.join("origin.git");
9168        let work = root.join("work");
9169        std::fs::create_dir_all(&origin).unwrap();
9170        std::fs::create_dir_all(&work).unwrap();
9171        test_git(&origin, &["init", "--bare", "-b", "main"]);
9172        test_git(&work, &["init", "-b", "main"]);
9173        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
9174        test_git(&work, &["config", "user.name", "spar test"]);
9175        test_git(&work, &["config", "commit.gpgsign", "false"]);
9176        test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
9177        test_git(&work, &["config", "filter.drop.smudge", "cat"]);
9178        std::fs::write(work.join("README.md"), "seed\n").unwrap();
9179        std::fs::write(work.join("data.txt"), "old\n").unwrap();
9180        std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
9181        std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
9182        test_git(&work, &["add", "."]);
9183        test_git(&work, &["commit", "-m", "seed"]);
9184        test_git(
9185            &work,
9186            &["remote", "add", "origin", origin.to_str().unwrap()],
9187        );
9188        test_git(&work, &["push", "-u", "origin", "main"]);
9189        test_git(
9190            &work,
9191            &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
9192        );
9193        let cfg = crate::config::parse(
9194            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
9195        )
9196        .unwrap();
9197        let repo = Repo::open(&work, &cfg).unwrap();
9198        let path = repo.worktree_for_pr_head(number).unwrap();
9199        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9200        (ReviewFixture { root }, repo, path, checkpoint)
9201    }
9202
9203    #[test]
9204    fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
9205        let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
9206
9207        repo.release_review_worktree_checked(901, &checkpoint)
9208            .unwrap();
9209
9210        assert!(!path.exists());
9211    }
9212
9213    #[test]
9214    fn a_force_pushed_pull_request_rebuilds_its_review_worktree() {
9215        let (fixture, repo, path, _checkpoint) = review_fixture("force-push", 923);
9216        let work = fixture.root.join("work");
9217        let seed = test_git(&work, &["rev-parse", "HEAD"]);
9218        let tree = test_git(&work, &["rev-parse", "HEAD^{tree}"]);
9219        // Heads only the pull request holds, the second rewritten rather than
9220        // built on the first, as a rebase and force push leave them.
9221        let publish = |message: &str| {
9222            let oid = test_git(
9223                &work,
9224                &["commit-tree", tree.trim(), "-p", seed.trim(), "-m", message],
9225            );
9226            let oid = oid.trim().to_string();
9227            test_git(
9228                &work,
9229                &[
9230                    "push",
9231                    "--force",
9232                    "origin",
9233                    &format!("{oid}:refs/pull/923/head"),
9234                ],
9235            );
9236            oid
9237        };
9238        let reviewed = publish("reviewed head");
9239        repo.worktree_for_pr_head(923).unwrap();
9240        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), reviewed);
9241
9242        let rewritten = publish("rewritten head");
9243        repo.worktree_for_pr_head(923).unwrap();
9244
9245        assert_eq!(test_git(&path, &["rev-parse", "HEAD"]).trim(), rewritten);
9246    }
9247
9248    #[test]
9249    fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
9250        let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
9251        let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
9252        std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
9253        test_git(&path, &["add", "recovery.txt"]);
9254        test_git(&path, &["commit", "-m", "recovery commit"]);
9255        let recovery = test_git(&path, &["rev-parse", "HEAD"]);
9256        test_git(&path, &["reset", "--hard", "main"]);
9257
9258        assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
9259        test_git(
9260            &path,
9261            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9262        );
9263    }
9264
9265    #[test]
9266    fn a_review_ref_reflog_only_commit_prevents_deletion() {
9267        let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
9268        let local_ref = review_ref(921);
9269        let original = test_git(&path, &["rev-parse", &local_ref]);
9270        let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
9271        let recovery = test_git(
9272            &path,
9273            &[
9274                "commit-tree",
9275                tree.trim(),
9276                "-p",
9277                original.trim(),
9278                "-m",
9279                "review ref recovery",
9280            ],
9281        );
9282        test_git(
9283            &path,
9284            &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
9285        );
9286        test_git(
9287            &path,
9288            &["update-ref", &local_ref, original.trim(), recovery.trim()],
9289        );
9290
9291        assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
9292        assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
9293        test_git(
9294            &path,
9295            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
9296        );
9297    }
9298
9299    #[test]
9300    fn an_unpublished_commit_message_draft_is_recoverable() {
9301        let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
9302        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
9303        let git_dir = if raw.is_absolute() {
9304            raw
9305        } else {
9306            path.join(raw)
9307        };
9308        std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
9309
9310        assert!(repository_has_recoverable_work(&path, true).unwrap());
9311        assert_eq!(
9312            "unique recovery draft\n",
9313            std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
9314        );
9315    }
9316
9317    #[test]
9318    fn a_changed_review_worktree_is_retained_after_a_checked_read() {
9319        let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
9320        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
9321
9322        let error = repo
9323            .release_review_worktree_checked(902, &checkpoint)
9324            .unwrap_err();
9325
9326        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9327        assert!(error.to_string().contains("kept for recovery"), "{error}");
9328        assert_eq!(
9329            "recover me\n",
9330            std::fs::read_to_string(path.join("README.md")).unwrap()
9331        );
9332        repo.release_review_worktree(902);
9333    }
9334
9335    #[test]
9336    fn a_review_commit_is_retained_after_a_checked_read() {
9337        let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
9338        std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
9339        test_git(&path, &["add", "review-note.txt"]);
9340        test_git(&path, &["commit", "-m", "local review recovery"]);
9341        let head = test_git(&path, &["rev-parse", "HEAD"]);
9342
9343        let error = repo
9344            .release_review_worktree_checked(903, &checkpoint)
9345            .unwrap_err();
9346
9347        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9348        assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
9349        assert_eq!(
9350            "recover me\n",
9351            std::fs::read_to_string(path.join("review-note.txt")).unwrap()
9352        );
9353        repo.release_review_worktree(903);
9354    }
9355
9356    /// The read is no longer what refuses: a file the call created and Git was
9357    /// told to ignore is its own output. Removal still refuses, so the file is
9358    /// there to look at, which is the part that mattered.
9359    #[test]
9360    fn an_ignored_review_file_is_retained_after_a_checked_read() {
9361        let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
9362        std::fs::create_dir_all(path.join("generated")).unwrap();
9363        std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
9364
9365        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9366            .expect("its own output is not a change");
9367        let error = repo
9368            .release_review_worktree_checked(904, &checkpoint)
9369            .unwrap_err();
9370
9371        assert!(error.to_string().contains("recoverable"), "{error}");
9372        assert_eq!(
9373            "recover me\n",
9374            std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
9375        );
9376        repo.release_review_worktree(904);
9377    }
9378
9379    #[test]
9380    /// The checkpoint no longer refuses this: a path the project ignores is out
9381    /// of scope for a call that only read. What keeps the file is the gate that
9382    /// actually deletes the checkout, which refuses any ignored file outside a
9383    /// known build directory. That is the check with the stake in it, and it is
9384    /// the one that has to hold.
9385    fn a_preexisting_ignored_review_file_change_is_retained() {
9386        let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
9387        std::fs::create_dir_all(path.join("generated")).unwrap();
9388        let ignored = path.join("generated/recovery.txt");
9389        std::fs::write(&ignored, "before\n").unwrap();
9390        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9391        std::fs::write(&ignored, "after!\n").unwrap();
9392
9393        let error = repo
9394            .release_review_worktree_checked(905, &checkpoint)
9395            .unwrap_err();
9396
9397        assert!(error.to_string().contains("recoverable"), "{error}");
9398        assert!(path.exists());
9399        assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
9400        repo.release_review_worktree(905);
9401    }
9402
9403    #[test]
9404    fn a_preexisting_ignored_review_file_prevents_checked_removal() {
9405        let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
9406        std::fs::create_dir_all(path.join("generated")).unwrap();
9407        let ignored = path.join("generated/recovery.txt");
9408        std::fs::write(&ignored, "keep me\n").unwrap();
9409        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9410
9411        let error = repo
9412            .release_review_worktree_checked(906, &checkpoint)
9413            .unwrap_err();
9414
9415        assert!(error.to_string().contains("recoverable"), "{error}");
9416        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
9417    }
9418
9419    #[test]
9420    fn overwriting_a_preexisting_untracked_file_is_detected() {
9421        let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
9422        let untracked = path.join("notes.txt");
9423        std::fs::write(&untracked, "before\n").unwrap();
9424        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9425        std::fs::write(&untracked, "after!\n").unwrap();
9426
9427        let error = repo
9428            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9429            .unwrap_err();
9430
9431        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9432        assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
9433    }
9434
9435    #[test]
9436    fn an_assume_unchanged_edit_is_detected() {
9437        let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
9438        test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
9439        std::fs::write(path.join("README.md"), "hidden\n").unwrap();
9440
9441        let error = repo
9442            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9443            .unwrap_err();
9444
9445        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9446        assert_eq!(
9447            "hidden\n",
9448            std::fs::read_to_string(path.join("README.md")).unwrap()
9449        );
9450    }
9451
9452    #[test]
9453    fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
9454        let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
9455        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9456        test_git(&path, &["add", "README.md"]);
9457        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9458
9459        let error = repo
9460            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9461            .unwrap_err();
9462
9463        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9464        assert_eq!(
9465            b"seed\r\n",
9466            std::fs::read(path.join("README.md")).unwrap().as_slice()
9467        );
9468    }
9469
9470    #[cfg(unix)]
9471    #[test]
9472    fn a_mode_edit_is_detected_when_filemode_is_disabled() {
9473        use std::os::unix::fs::PermissionsExt;
9474
9475        let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
9476        test_git(&path, &["config", "core.filemode", "false"]);
9477        let readme = path.join("README.md");
9478        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9479        permissions.set_mode(0o755);
9480        std::fs::set_permissions(&readme, permissions).unwrap();
9481        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9482
9483        let error = repo
9484            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9485            .unwrap_err();
9486
9487        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9488        assert_eq!(
9489            0o755,
9490            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9491        );
9492    }
9493
9494    #[test]
9495    fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
9496        let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
9497        std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
9498        test_git(&path, &["add", ".gitattributes"]);
9499        test_git(&path, &["commit", "-m", "select data filter"]);
9500        let baseline = repo.worktree_baseline(&path).unwrap();
9501        std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
9502
9503        assert!(repo
9504            .commit_pending_changes(&path, &baseline, "change data", "change data")
9505            .unwrap());
9506        let error = repo
9507            .refuse_unrepresented_tracked_changes(&path, &baseline)
9508            .unwrap_err();
9509
9510        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9511        assert_eq!(
9512            "secret: recover me\nnew\n",
9513            std::fs::read_to_string(path.join("data.txt")).unwrap()
9514        );
9515        assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
9516    }
9517
9518    #[test]
9519    fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
9520        let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
9521        std::fs::create_dir_all(path.join("target")).unwrap();
9522        let untracked = path.join("target/user.yaml");
9523        std::fs::write(&untracked, "user data\n").unwrap();
9524        let baseline = repo.worktree_baseline(&path).unwrap();
9525        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9526
9527        assert!(repo
9528            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9529            .unwrap());
9530
9531        assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
9532        assert_eq!(
9533            "?? target/user.yaml\n",
9534            test_git(&path, &["status", "--short", "--untracked-files=all"])
9535        );
9536        assert!(test_git(
9537            &path,
9538            &[
9539                "ls-tree",
9540                "-r",
9541                "--name-only",
9542                "HEAD",
9543                "--",
9544                "target/user.yaml"
9545            ]
9546        )
9547        .is_empty());
9548    }
9549
9550    #[test]
9551    fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
9552        let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
9553        std::fs::create_dir_all(path.join("target")).unwrap();
9554        let untracked = path.join("target/user.yaml");
9555        std::fs::write(&untracked, "before\n").unwrap();
9556        let baseline = repo.worktree_baseline(&path).unwrap();
9557        let before = test_git(&path, &["rev-parse", "HEAD"]);
9558        std::fs::write(&untracked, "after\n").unwrap();
9559        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9560
9561        let error = repo
9562            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9563            .unwrap_err();
9564
9565        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9566        assert!(error.to_string().contains("target/user.yaml"), "{error}");
9567        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
9568        assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
9569        assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
9570    }
9571
9572    #[test]
9573    fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
9574        let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
9575        let baseline = repo.worktree_baseline(&path).unwrap();
9576        std::fs::create_dir_all(path.join("target")).unwrap();
9577        std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
9578
9579        assert!(repo
9580            .commit_pending_changes(&path, &baseline, "add file", "add file")
9581            .unwrap());
9582
9583        assert_eq!(
9584            "new file\n",
9585            test_git(&path, &["show", "HEAD:target/new.txt"])
9586        );
9587        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9588    }
9589
9590    #[test]
9591    fn a_managed_commit_accepts_a_custom_output_rebuild() {
9592        let (_fixture, repo, path, _checkpoint) = review_fixture("custom-output", 912);
9593        exclude_paths(&repo, &["manager/public/"]);
9594        let output = path.join("manager/public/assets");
9595        std::fs::create_dir_all(&output).unwrap();
9596        std::fs::write(output.join("old.js"), "old bundle\n").unwrap();
9597        std::fs::write(output.join("index.css"), "old styles\n").unwrap();
9598        let baseline = repo.worktree_baseline(&path).unwrap();
9599        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9600        std::fs::write(path.join("new-source.txt"), "new source\n").unwrap();
9601        std::fs::remove_file(output.join("old.js")).unwrap();
9602        std::fs::write(output.join("index.css"), "new styles\n").unwrap();
9603        std::fs::write(output.join("index-DUym-Rfj.js"), "new bundle\n").unwrap();
9604
9605        assert!(repo
9606            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
9607            .unwrap());
9608        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
9609        repo.refuse_changed_existing_untracked(&path, &baseline)
9610            .unwrap();
9611        repo.refuse_unrepresented_tracked_changes(&path, &baseline)
9612            .unwrap();
9613
9614        assert_eq!(
9615            "tracked change\n",
9616            test_git(&path, &["show", "HEAD:README.md"])
9617        );
9618        assert_eq!(
9619            "new source\n",
9620            test_git(&path, &["show", "HEAD:new-source.txt"])
9621        );
9622        assert!(test_git(
9623            &path,
9624            &[
9625                "ls-tree",
9626                "-r",
9627                "--name-only",
9628                "HEAD",
9629                "--",
9630                "manager/public"
9631            ]
9632        )
9633        .is_empty());
9634        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9635        assert!(!output.join("old.js").exists());
9636        assert_eq!(
9637            "new styles\n",
9638            std::fs::read_to_string(output.join("index.css")).unwrap()
9639        );
9640        assert!(repository_has_recoverable_work(&path, true).unwrap());
9641        repo.release_review_worktree(912);
9642        assert!(output.join("index-DUym-Rfj.js").exists());
9643    }
9644
9645    #[test]
9646    fn newly_ignoring_existing_untracked_work_does_not_allow_its_modification() {
9647        let (_fixture, repo, path, _checkpoint) = review_fixture("newly-ignored", 926);
9648        std::fs::write(path.join("notes.txt"), "original notes\n").unwrap();
9649        let baseline = repo.worktree_baseline(&path).unwrap();
9650        std::fs::write(path.join(".gitignore"), "generated/\nnotes.txt\n").unwrap();
9651        std::fs::write(path.join("notes.txt"), "overwritten notes\n").unwrap();
9652
9653        let error = repo
9654            .commit_pending_changes(&path, &baseline, "change", "change")
9655            .unwrap_err();
9656
9657        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9658        assert!(error.to_string().contains("notes.txt"), "{error}");
9659    }
9660
9661    #[test]
9662    fn a_new_ignored_nested_repository_allows_a_commit_but_is_preserved() {
9663        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-nested-edit", 927);
9664        let baseline = repo.worktree_baseline(&path).unwrap();
9665        let nested = path.join("generated/repro");
9666        std::fs::create_dir_all(&nested).unwrap();
9667        test_git(&nested, &["init"]);
9668        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9669
9670        assert!(repo
9671            .commit_pending_changes(&path, &baseline, "change", "change")
9672            .unwrap());
9673        assert!(!repo.has_uncommitted_changes(&path).unwrap());
9674        assert!(repository_has_recoverable_work(&path, true).unwrap());
9675        repo.release_review_worktree(927);
9676        assert!(nested.join(".git").exists());
9677    }
9678
9679    #[test]
9680    fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
9681        let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
9682        test_git(&path, &["config", "core.autocrlf", "true"]);
9683        std::fs::write(path.join("README.md"), "seed\n").unwrap();
9684        assert_eq!(
9685            test_git(&path, &["hash-object", "README.md"]).trim(),
9686            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9687        );
9688
9689        assert!(repository_has_recoverable_work(&path, true).unwrap());
9690        assert_eq!(
9691            "seed\n",
9692            std::fs::read_to_string(path.join("README.md")).unwrap()
9693        );
9694    }
9695
9696    #[test]
9697    fn autocrlf_input_overrides_a_crlf_core_eol() {
9698        let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
9699        test_git(&path, &["config", "core.autocrlf", "input"]);
9700        test_git(&path, &["config", "core.eol", "crlf"]);
9701        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
9702        assert_eq!(
9703            test_git(&path, &["hash-object", "README.md"]).trim(),
9704            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
9705        );
9706
9707        assert!(repository_has_recoverable_work(&path, true).unwrap());
9708        assert_eq!(
9709            b"seed\r\n",
9710            std::fs::read(path.join("README.md")).unwrap().as_slice()
9711        );
9712    }
9713
9714    #[cfg(unix)]
9715    #[test]
9716    fn a_non_executable_permission_change_is_recoverable() {
9717        use std::os::unix::fs::PermissionsExt;
9718
9719        let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
9720        let readme = path.join("README.md");
9721        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
9722        permissions.set_mode(0o600);
9723        std::fs::set_permissions(&readme, permissions).unwrap();
9724        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
9725
9726        let error = repo
9727            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9728            .unwrap_err();
9729
9730        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9731        assert!(repository_has_recoverable_work(&path, true).unwrap());
9732        assert_eq!(
9733            0o600,
9734            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
9735        );
9736    }
9737
9738    #[cfg(unix)]
9739    #[test]
9740    fn a_managed_commit_skips_signing_and_hooks() {
9741        use std::os::unix::fs::PermissionsExt;
9742
9743        let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
9744        let common = common_git_dir(&path).unwrap();
9745        let hook = common.join("hooks/pre-commit");
9746        let marker = fixture.root.join("hook-ran");
9747        std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
9748        std::fs::write(
9749            &hook,
9750            format!(
9751                "#!/bin/sh\nprintf ran > {}\nexit 1\n",
9752                sh_quote(marker.to_str().unwrap())
9753            ),
9754        )
9755        .unwrap();
9756        let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
9757        permissions.set_mode(0o755);
9758        std::fs::set_permissions(&hook, permissions).unwrap();
9759        test_git(&path, &["config", "commit.gpgsign", "true"]);
9760        test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
9761        std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
9762        test_git(&path, &["add", "managed.txt"]);
9763
9764        repo.commit_staged_changes(&path, "record managed change")
9765            .unwrap();
9766
9767        assert!(!marker.exists());
9768        assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
9769    }
9770
9771    #[test]
9772    fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
9773        let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
9774        std::fs::write(
9775            path.join(".gitattributes"),
9776            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
9777        )
9778        .unwrap();
9779        test_git(&path, &["add", ".gitattributes"]);
9780        test_git(&path, &["commit", "-m", "select automatic text"]);
9781        test_git(&path, &["config", "core.autocrlf", "true"]);
9782
9783        assert!(repository_has_recoverable_work(&path, true).unwrap());
9784    }
9785
9786    #[test]
9787    fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
9788        let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
9789        std::fs::write(
9790            path.join(".gitattributes"),
9791            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
9792        )
9793        .unwrap();
9794        test_git(&path, &["add", ".gitattributes"]);
9795        test_git(&path, &["commit", "-m", "select ident expansion"]);
9796        std::fs::write(path.join("README.md"), "seed\n").unwrap();
9797
9798        assert!(repository_has_recoverable_work(&path, true).unwrap());
9799    }
9800
9801    /// Add ignore rules the way SPAR does, without a tracked change the
9802    /// worktree would then be kept for.
9803    /// The failure this whole rule was rewritten for: a reviewer ran the
9804    /// project's binding generator to check a finding, and the lockfile it left
9805    /// beside its own manifest matched no directory name spar knew.
9806    #[test]
9807    fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
9808        let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
9809        exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
9810        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9811        std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
9812        std::fs::write(
9813            path.join("bindings/uniffi-bindgen/Cargo.lock"),
9814            "# generated\n",
9815        )
9816        .unwrap();
9817
9818        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9819            .expect("a path the project ignores is the call's own output");
9820    }
9821
9822    #[test]
9823    fn an_attribute_file_under_build_output_is_not_a_filter_change() {
9824        let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
9825        exclude_paths(&repo, &["target/"]);
9826        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9827        std::fs::create_dir_all(path.join("target/package")).unwrap();
9828        std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
9829
9830        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9831            .expect("an ignored attribute file governs only ignored paths");
9832        assert!(
9833            !repo.has_uncommitted_changes(&path).unwrap(),
9834            "ignored dependency attributes must not stop the next review"
9835        );
9836    }
9837
9838    #[test]
9839    fn ordinary_untracked_attributes_still_count_as_uncommitted_work() {
9840        let (_fixture, repo, path, _initial) = review_fixture("ordinary-attributes", 942);
9841        std::fs::create_dir_all(path.join("source")).unwrap();
9842        std::fs::write(path.join("source/.gitattributes"), "* -text\n").unwrap();
9843
9844        assert!(repo.has_uncommitted_changes(&path).unwrap());
9845    }
9846
9847    #[test]
9848    fn all_standard_ignore_sources_allow_artifact_changes_and_preserve_data() {
9849        for source in ["repository", "nested", "local", "global"] {
9850            let (_fixture, repo, path, _initial) = review_fixture(source, 945);
9851            std::fs::create_dir_all(path.join("manager")).unwrap();
9852            match source {
9853                "repository" => {
9854                    std::fs::write(path.join(".gitignore"), "generated/\nmanager/scratch/\n")
9855                        .unwrap();
9856                    test_git(&path, &["add", ".gitignore"]);
9857                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
9858                }
9859                "nested" => {
9860                    std::fs::write(path.join("manager/.gitignore"), "scratch/\n").unwrap();
9861                    test_git(&path, &["add", "manager/.gitignore"]);
9862                    test_git(&path, &["commit", "-m", "ignore artifacts"]);
9863                }
9864                "local" => exclude_paths(&repo, &["manager/scratch/"]),
9865                "global" => {
9866                    let excludes = repo.root().parent().unwrap().join("global-ignore");
9867                    std::fs::write(&excludes, "manager/scratch/\n").unwrap();
9868                    test_git(
9869                        &path,
9870                        &["config", "core.excludesFile", excludes.to_str().unwrap()],
9871                    );
9872                }
9873                _ => unreachable!(),
9874            }
9875            let output = path.join("manager/scratch");
9876            std::fs::create_dir_all(&output).unwrap();
9877            std::fs::write(output.join(".env.local"), "LOCAL=value\n").unwrap();
9878            std::fs::write(output.join(".DS_Store"), "old metadata").unwrap();
9879            let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9880            std::fs::write(output.join(".env.local"), "LOCAL=updated\n").unwrap();
9881            std::fs::remove_file(output.join(".DS_Store")).unwrap();
9882            for file in [
9883                ".gitattributes",
9884                "Cargo.lock",
9885                "font.woff2",
9886                "bundle.js",
9887                "cache.db",
9888            ] {
9889                std::fs::write(output.join(file), "generated\n").unwrap();
9890            }
9891            #[cfg(unix)]
9892            std::os::unix::fs::symlink("bundle.js", output.join("bundle-link")).unwrap();
9893            let nested = output.join("dependency");
9894            std::fs::create_dir_all(&nested).unwrap();
9895            test_git(&nested, &["init"]);
9896            repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9897                .unwrap_or_else(|error| panic!("{source}: {error}"));
9898            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9899
9900            let baseline = repo.worktree_baseline(&path).unwrap();
9901            std::fs::write(path.join("README.md"), "source fix\n").unwrap();
9902            std::fs::write(output.join("bundle.js"), "rebuilt\n").unwrap();
9903            std::fs::remove_file(output.join("Cargo.lock")).unwrap();
9904            std::fs::write(output.join("new-lock.json"), "{}\n").unwrap();
9905            std::fs::write(nested.join("uncommitted.txt"), "dependency output\n").unwrap();
9906            assert!(repo
9907                .commit_pending_changes(&path, &baseline, "fix source", "fix source")
9908                .unwrap_or_else(|error| panic!("{source}: {error}")));
9909            assert!(!repo.has_uncommitted_changes(&path).unwrap(), "{source}");
9910            assert!(test_git(
9911                &path,
9912                &[
9913                    "ls-tree",
9914                    "-r",
9915                    "--name-only",
9916                    "HEAD",
9917                    "--",
9918                    "manager/scratch"
9919                ]
9920            )
9921            .is_empty());
9922            assert!(
9923                repository_has_recoverable_work(&path, true).unwrap(),
9924                "{source}"
9925            );
9926            repo.release_review_worktree(945);
9927            assert_eq!(
9928                "LOCAL=updated\n",
9929                std::fs::read_to_string(output.join(".env.local")).unwrap()
9930            );
9931            assert!(nested.join(".git").exists(), "{source}");
9932        }
9933    }
9934
9935    /// Two reviewers share one checkout. The marker written when the first
9936    /// one's answer was refused used to be a brand new untracked file in the
9937    /// snapshot the second one was compared against.
9938    #[test]
9939    fn a_recovery_marker_is_not_a_change_to_the_worktree() {
9940        let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
9941        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9942        let _ = uncertain_worktree_change(&path, "an earlier call was refused");
9943
9944        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
9945            .expect("spar's own marker is not something a call did");
9946    }
9947
9948    #[test]
9949    fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
9950        let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
9951
9952        let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
9953            .expect("a path a build removed is not an unreadable worktree");
9954
9955        assert_eq!(vanished_untracked_file(), gone);
9956    }
9957
9958    /// A path that goes is still a path that was there. Dropping it from the
9959    /// snapshot would let it pass for one the call never touched.
9960    #[test]
9961    fn a_vanished_path_is_not_recorded_as_absent() {
9962        let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
9963        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
9964        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
9965        std::fs::remove_file(path.join("notes.txt")).unwrap();
9966
9967        let error = repo
9968            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
9969            .unwrap_err();
9970
9971        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
9972    }
9973
9974    fn exclude_paths(repo: &Repo, lines: &[&str]) {
9975        use std::io::Write;
9976        let path = repo.root().join(".git").join("info").join("exclude");
9977        let mut file = std::fs::OpenOptions::new()
9978            .create(true)
9979            .append(true)
9980            .open(&path)
9981            .unwrap();
9982        for line in lines {
9983            writeln!(file, "{line}").unwrap();
9984        }
9985    }
9986
9987    #[test]
9988    fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
9989        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
9990        exclude_paths(&repo, &["dist/"]);
9991        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
9992        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
9993        let baseline = repo.worktree_baseline(&path).unwrap();
9994        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
9995        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
9996        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
9997        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
9998
9999        assert!(repo
10000            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
10001            .unwrap());
10002        assert_eq!(
10003            "tracked change\n",
10004            std::fs::read_to_string(path.join("README.md")).unwrap()
10005        );
10006    }
10007
10008    #[test]
10009    fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
10010        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
10011        exclude_paths(&repo, &["dist/"]);
10012        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
10013        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
10014        std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
10015        let baseline = repo.worktree_baseline(&path).unwrap();
10016        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
10017        std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
10018        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
10019        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
10020
10021        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10022        repo.refuse_changed_existing_untracked(&path, &baseline)
10023            .unwrap();
10024    }
10025
10026    #[test]
10027    fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
10028        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
10029        exclude_paths(&repo, &["dist/"]);
10030        std::fs::create_dir_all(path.join("dist")).unwrap();
10031        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10032        let baseline = repo.worktree_baseline(&path).unwrap();
10033        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10034        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10035
10036        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10037        repo.refuse_changed_existing_untracked(&path, &baseline)
10038            .unwrap();
10039    }
10040
10041    /// The run this comes from: a reviewer rebuilt a project whose build output
10042    /// lands in `manager/public/assets/`, which no directory list will ever
10043    /// name. The guard inside the call kept the review. This check then called
10044    /// the same thirteen files unrepresentable and ended the round, five
10045    /// minutes of review thrown away over a path Git had been told to disown.
10046    /// The run this comes from: `gh pr merge --delete-branch` could not delete
10047    /// `issue-784` because `.spar-worktrees/issue-784`, left by the run that
10048    /// opened the pull request, still had it checked out.
10049    #[test]
10050    fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
10051        let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
10052        let held = repo.root().join(WORKTREE_DIR).join("issue-950");
10053        std::fs::create_dir_all(held.parent().unwrap()).unwrap();
10054        test_git(
10055            repo.root(),
10056            &[
10057                "worktree",
10058                "add",
10059                "-b",
10060                "issue-950",
10061                held.to_str().unwrap(),
10062                "main",
10063            ],
10064        );
10065
10066        let found = repo
10067            .worktree_holding("issue-950")
10068            .expect("the branch is checked out somewhere");
10069
10070        assert!(found.ends_with("issue-950"), "{}", found.display());
10071        assert_eq!(None, repo.worktree_holding("issue-951"));
10072    }
10073
10074    #[test]
10075    fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
10076        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
10077        exclude_paths(&repo, &["manager/public/assets/"]);
10078        std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
10079        let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
10080        std::fs::write(&bundle, "first build\n").unwrap();
10081        let baseline = repo.worktree_baseline(&path).unwrap();
10082        std::fs::write(&bundle, "second build\n").unwrap();
10083        std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
10084
10085        repo.refuse_read_only_leavings(&path, &baseline).unwrap();
10086        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10087    }
10088
10089    #[test]
10090    fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
10091        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
10092        exclude_paths(&repo, &["dist/"]);
10093        let baseline = repo.worktree_baseline(&path).unwrap();
10094        std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
10095
10096        let error = repo
10097            .refuse_read_only_leavings(&path, &baseline)
10098            .unwrap_err();
10099
10100        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10101        assert!(error.to_string().contains("scratch.md"), "{error}");
10102    }
10103
10104    #[test]
10105    fn a_new_ignored_file_is_accepted_but_still_keeps_the_worktree() {
10106        let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
10107        exclude_paths(&repo, &["dist/", ".env.local"]);
10108        std::fs::create_dir_all(path.join("dist")).unwrap();
10109        let baseline = repo.worktree_baseline(&path).unwrap();
10110        std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
10111        std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
10112
10113        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
10114        assert!(repository_has_recoverable_work(&path, true).unwrap());
10115    }
10116
10117    #[test]
10118    fn a_read_only_inspection_may_rebuild_generated_output() {
10119        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
10120        exclude_paths(&repo, &["dist/"]);
10121        std::fs::create_dir_all(path.join("dist")).unwrap();
10122        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
10123        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10124        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
10125        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
10126
10127        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10128            .unwrap();
10129    }
10130
10131    #[test]
10132    fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
10133        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
10134        exclude_paths(&repo, &["dist/", ".env.local"]);
10135        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10136        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10137        std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
10138
10139        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10140            .unwrap();
10141    }
10142
10143    #[test]
10144    fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
10145        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
10146        exclude_paths(&repo, &["dist/", ".env.local"]);
10147        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
10148        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10149        std::fs::remove_file(path.join(".env.local")).unwrap();
10150
10151        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10152            .unwrap();
10153    }
10154
10155    #[test]
10156    fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
10157        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
10158        exclude_paths(&repo, &["dist/"]);
10159        std::fs::write(path.join("notes.md"), "before\n").unwrap();
10160        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10161        std::fs::write(path.join("notes.md"), "after\n").unwrap();
10162
10163        let error = repo
10164            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10165            .unwrap_err();
10166
10167        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10168        assert!(error.to_string().contains("notes.md"), "{error}");
10169    }
10170
10171    #[test]
10172    fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
10173        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
10174        exclude_paths(&repo, &["dist/", "local.env"]);
10175        std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
10176        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10177        // Stop ignoring it, so the same bytes are now an ordinary untracked
10178        // file. What a path is counts, not only whether it moved.
10179        std::fs::write(
10180            repo.root().join(".git").join("info").join("exclude"),
10181            "dist/\n",
10182        )
10183        .unwrap();
10184
10185        let error = repo
10186            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10187            .unwrap_err();
10188
10189        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10190        assert!(error.to_string().contains("local.env"), "{error}");
10191    }
10192
10193    #[test]
10194    fn a_read_only_inspection_names_the_tracked_file_that_moved() {
10195        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
10196        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10197        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
10198
10199        let error = repo
10200            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10201            .unwrap_err();
10202
10203        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10204        assert!(error.to_string().contains("README.md"), "{error}");
10205    }
10206
10207    #[test]
10208    fn build_output_alone_does_not_keep_a_worktree() {
10209        let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
10210        exclude_paths(&repo, &["target/", "dist/"]);
10211        std::fs::create_dir_all(path.join("target/debug")).unwrap();
10212        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10213        std::fs::create_dir_all(path.join("dist/cli")).unwrap();
10214        std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
10215
10216        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10217        repo.release_review_worktree(933);
10218
10219        assert!(!path.exists());
10220    }
10221
10222    #[test]
10223    fn finder_metadata_alone_does_not_keep_a_worktree() {
10224        let (_fixture, repo, path, _checkpoint) = review_fixture("finder-metadata", 937);
10225        exclude_paths(&repo, &[".DS_Store"]);
10226        std::fs::create_dir_all(path.join("src")).unwrap();
10227        std::fs::write(path.join(".DS_Store"), "finder state\n").unwrap();
10228        std::fs::write(path.join("src/.DS_Store"), "finder state\n").unwrap();
10229
10230        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10231        repo.release_review_worktree(937);
10232
10233        assert!(!path.exists());
10234    }
10235
10236    #[test]
10237    fn a_fetch_record_alone_does_not_keep_a_worktree() {
10238        let (_fixture, repo, path, _checkpoint) = review_fixture("fetch-record", 938);
10239        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
10240        let git_dir = if raw.is_absolute() {
10241            raw
10242        } else {
10243            path.join(raw)
10244        };
10245        let head = test_git(&path, &["rev-parse", "HEAD"]);
10246        std::fs::write(
10247            git_dir.join("FETCH_HEAD"),
10248            format!("{}\t\tbranch 'main' of origin\n", head.trim()),
10249        )
10250        .unwrap();
10251
10252        assert!(!repository_has_recoverable_work(&path, true).unwrap());
10253        repo.release_review_worktree(938);
10254
10255        assert!(!path.exists());
10256    }
10257
10258    #[test]
10259    fn an_ignored_file_outside_build_output_keeps_a_worktree() {
10260        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
10261        exclude_paths(&repo, &["target/", ".env.local"]);
10262        std::fs::create_dir_all(path.join("target/debug")).unwrap();
10263        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
10264        std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
10265
10266        assert!(repository_has_recoverable_work(&path, true).unwrap());
10267        repo.release_review_worktree(934);
10268
10269        assert_eq!(
10270            "TOKEN=keep me\n",
10271            std::fs::read_to_string(path.join(".env.local")).unwrap()
10272        );
10273    }
10274
10275    #[test]
10276    fn a_repository_nested_in_build_output_keeps_a_worktree() {
10277        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
10278        exclude_paths(&repo, &["node_modules/"]);
10279        let nested = path.join("node_modules/local-dep");
10280        std::fs::create_dir_all(&nested).unwrap();
10281        test_git(&nested, &["init"]);
10282        std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
10283
10284        assert!(repository_has_recoverable_work(&path, true).unwrap());
10285        repo.release_review_worktree(935);
10286
10287        assert!(nested.join(".git").exists());
10288    }
10289
10290    #[test]
10291    fn an_ordinary_untracked_file_keeps_a_worktree() {
10292        let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
10293        std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
10294
10295        assert!(repository_has_recoverable_work(&path, true).unwrap());
10296        repo.release_review_worktree(936);
10297
10298        assert_eq!(
10299            "somebody's notes\n",
10300            std::fs::read_to_string(path.join("notes.md")).unwrap()
10301        );
10302    }
10303
10304    #[test]
10305    fn a_legacy_crlf_checkout_is_retained_conservatively() {
10306        let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
10307        std::fs::write(
10308            path.join(".gitattributes"),
10309            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
10310        )
10311        .unwrap();
10312        test_git(&path, &["add", ".gitattributes"]);
10313        test_git(&path, &["commit", "-m", "select legacy line endings"]);
10314
10315        assert!(repository_has_recoverable_work(&path, true).unwrap());
10316    }
10317
10318    #[test]
10319    fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
10320        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
10321        let nested = path.join("tracked");
10322        std::fs::create_dir_all(&nested).unwrap();
10323        std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
10324        test_git(&path, &["add", "tracked/seed.txt"]);
10325        test_git(&path, &["commit", "-m", "add tracked directory"]);
10326        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10327        test_git(&nested, &["init"]);
10328
10329        let error = repo
10330            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
10331            .unwrap_err();
10332
10333        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10334        assert!(error.to_string().contains("Git entry"), "{error}");
10335        assert!(nested.join(".git").exists());
10336    }
10337
10338    /// A reviewer that clones a reproduction into ignored build output used to
10339    /// lose the whole review. The state check refused every Git entry it had
10340    /// not put there, including the one Git reports as a nested checkout and
10341    /// the untracked snapshot already records by identity.
10342    #[test]
10343    fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
10344        let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
10345        exclude_paths(&repo, &["target/"]);
10346        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10347        let repro = path.join("target/pr918-repro");
10348        std::fs::create_dir_all(&repro).unwrap();
10349        test_git(&repro, &["init"]);
10350        std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
10351
10352        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10353            .unwrap();
10354
10355        assert!(repository_has_recoverable_work(&path, true).unwrap());
10356        assert!(repro.join(".git").exists());
10357    }
10358
10359    /// What sits under a nested checkout is that repository's business, so the
10360    /// scan stops at its directory rather than reading a checkout of its own
10361    /// as an entry the outer worktree has to account for.
10362    #[test]
10363    fn a_repository_inside_a_nested_checkout_is_left_alone() {
10364        let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
10365        exclude_paths(&repo, &["target/"]);
10366        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
10367        let repro = path.join("target/repro");
10368        let vendored = repro.join("vendor/dep");
10369        std::fs::create_dir_all(&vendored).unwrap();
10370        test_git(&repro, &["init"]);
10371        test_git(&vendored, &["init"]);
10372
10373        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
10374            .unwrap();
10375
10376        assert!(vendored.join(".git").exists());
10377    }
10378
10379    #[test]
10380    fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
10381        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
10382
10383        let state = ignored_untracked_state(repo.root()).unwrap();
10384
10385        let relative = path.strip_prefix(repo.root()).unwrap();
10386        assert!(
10387            state.files.contains_key(relative),
10388            "{:?}",
10389            state.files.keys().collect::<Vec<_>>()
10390        );
10391        assert!(state.is_ignored(relative));
10392    }
10393
10394    #[test]
10395    fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
10396        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
10397        let baseline = repo.worktree_baseline(repo.root()).unwrap();
10398        std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
10399        std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
10400
10401        repo.refuse_new_ignored_files(repo.root(), &baseline)
10402            .unwrap();
10403        repo.refuse_changed_existing_untracked(repo.root(), &baseline)
10404            .unwrap();
10405    }
10406
10407    #[test]
10408    fn deleting_a_resident_worktree_during_a_call_is_refused() {
10409        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
10410        let baseline = repo.worktree_baseline(repo.root()).unwrap();
10411        let checkpoint = repo.worktree_checkpoint(repo.root()).unwrap();
10412        std::fs::remove_dir_all(&path).unwrap();
10413
10414        let read_error = repo
10415            .require_unchanged_worktree(repo.root(), &checkpoint, "main checkout")
10416            .unwrap_err();
10417        assert!(
10418            read_error.to_string().contains("review-932"),
10419            "{read_error}"
10420        );
10421        let error = repo
10422            .refuse_new_ignored_files(repo.root(), &baseline)
10423            .unwrap_err();
10424
10425        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10426        assert!(error.to_string().contains("review-932"), "{error}");
10427    }
10428
10429    #[test]
10430    fn a_nested_repository_record_is_read_as_a_plain_path() {
10431        let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
10432        assert_eq!(Path::new("vendor/checkout"), path);
10433        assert!(nested);
10434
10435        let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
10436        assert_eq!(Path::new("vendor/notes.txt"), path);
10437        assert!(!nested);
10438
10439        assert!(untracked_record(b"/", "untracked").is_err());
10440    }
10441
10442    #[cfg(unix)]
10443    #[test]
10444    fn a_non_utf8_git_path_is_preserved_without_loss() {
10445        use std::os::unix::ffi::OsStrExt;
10446
10447        let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
10448
10449        assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
10450    }
10451
10452    #[test]
10453    fn guarded_merge_pins_the_reviewed_head() {
10454        let args = merge_pr_args("36", Some("abc123"), true);
10455        assert_eq!(
10456            vec![
10457                "pr",
10458                "merge",
10459                "36",
10460                "--squash",
10461                "--delete-branch",
10462                "--match-head-commit",
10463                "abc123"
10464            ],
10465            args
10466        );
10467    }
10468
10469    #[test]
10470    fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
10471        let pr = PrRef {
10472            number: 7,
10473            url: "https://example.test/pull/7".into(),
10474            title: "part one".into(),
10475        };
10476        let result = reconcile_pr_creation(
10477            "split-34-1",
10478            Err(crate::error::SparError::new("connection lost")),
10479            Ok(Some(pr)),
10480        )
10481        .unwrap();
10482        assert_eq!(7, result.number);
10483    }
10484
10485    #[test]
10486    fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
10487        let error = reconcile_pr_creation(
10488            "split-34-1",
10489            Err(crate::error::SparError::new("permission denied")),
10490            Ok(None),
10491        )
10492        .unwrap_err();
10493        assert!(error.to_string().contains("permission denied"), "{error}");
10494    }
10495
10496    #[test]
10497    fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
10498        let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
10499        assert!(pr_for_base(text, "split-34-2", "split-34-1")
10500            .unwrap()
10501            .is_none());
10502        let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
10503        assert_eq!(7, found.number);
10504    }
10505
10506    #[test]
10507    fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
10508        let result = reconcile_comment_post(
10509            34,
10510            "the summary",
10511            crate::error::SparError::new("connection lost"),
10512            Ok(vec![serde_json::json!({"body": "the summary"})]),
10513        );
10514        assert!(result.is_ok(), "{result:?}");
10515    }
10516
10517    #[test]
10518    fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
10519        let error = reconcile_comment_post(
10520            34,
10521            "the summary",
10522            crate::error::SparError::new("connection lost"),
10523            Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
10524        )
10525        .unwrap_err();
10526        assert_eq!("connection lost", error.to_string());
10527    }
10528
10529    #[test]
10530    fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
10531        let error = reconcile_comment_post(
10532            34,
10533            "the summary",
10534            crate::error::SparError::new("connection lost"),
10535            Err(crate::error::SparError::new("comments unavailable")),
10536        )
10537        .unwrap_err();
10538        assert!(
10539            error.to_string().contains("could not be verified"),
10540            "{error}"
10541        );
10542        assert!(
10543            error.to_string().contains("comments unavailable"),
10544            "{error}"
10545        );
10546        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10547        assert!(!error.worth_retrying());
10548    }
10549
10550    #[test]
10551    fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
10552        let result = reconcile_issue_edit(
10553            34,
10554            "wanted body",
10555            crate::error::SparError::new("connection lost"),
10556            Ok("wanted body".to_string()),
10557        );
10558        assert!(result.is_ok(), "{result:?}");
10559    }
10560
10561    #[test]
10562    fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
10563        let error = reconcile_issue_edit(
10564            34,
10565            "wanted body",
10566            crate::error::SparError::new("connection lost"),
10567            Err(crate::error::SparError::new("issue unavailable")),
10568        )
10569        .unwrap_err();
10570        assert!(
10571            error.to_string().contains("could not be verified"),
10572            "{error}"
10573        );
10574        assert!(error.to_string().contains("issue unavailable"), "{error}");
10575        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10576        assert!(!error.worth_retrying());
10577    }
10578
10579    #[test]
10580    fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
10581        let found = ExistingIssue {
10582            number: 101,
10583            url: "https://example.test/issues/101".into(),
10584            title: "child".into(),
10585            body: "body".into(),
10586            open: true,
10587        };
10588        let url = reconcile_issue_creation(
10589            "child",
10590            Err(crate::error::SparError::new("connection lost")),
10591            Ok(Some(found)),
10592        )
10593        .unwrap();
10594        assert_eq!("https://example.test/issues/101", url);
10595    }
10596
10597    #[test]
10598    fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
10599        let error = reconcile_issue_creation(
10600            "child",
10601            Err(crate::error::SparError::new("permission denied")),
10602            Ok(None),
10603        )
10604        .unwrap_err();
10605        assert!(error.to_string().contains("permission denied"), "{error}");
10606    }
10607
10608    #[test]
10609    fn an_unverifiable_issue_creation_is_marked_uncertain() {
10610        let error = reconcile_issue_creation(
10611            "child",
10612            Err(crate::error::SparError::new("connection lost")),
10613            Err(crate::error::SparError::new("issues unavailable")),
10614        )
10615        .unwrap_err();
10616        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
10617        assert!(!error.worth_retrying());
10618    }
10619
10620    #[test]
10621    fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
10622        let result = reconcile_failed_split_push(
10623            "split-34-1",
10624            crate::error::SparError::new("connection lost"),
10625            Ok("abc123\n".into()),
10626            Ok("abc123\trefs/heads/split-34-1\n".into()),
10627        );
10628        assert!(result.is_ok(), "{result:?}");
10629    }
10630
10631    #[test]
10632    fn a_split_push_collision_is_definite_and_never_overwrites() {
10633        let error = reconcile_failed_split_push(
10634            "split-34-1",
10635            crate::error::SparError::new("lease rejected"),
10636            Ok("abc123\n".into()),
10637            Ok("def456\trefs/heads/split-34-1\n".into()),
10638        )
10639        .unwrap_err();
10640        assert!(!error.retain_worktree());
10641        assert!(
10642            error.to_string().contains("Nothing was overwritten"),
10643            "{error}"
10644        );
10645    }
10646
10647    #[test]
10648    fn an_unreadable_split_push_result_keeps_the_worktree() {
10649        let error = reconcile_failed_split_push(
10650            "split-34-1",
10651            crate::error::SparError::new("connection lost"),
10652            Ok("abc123\n".into()),
10653            Err(crate::error::SparError::new("origin unavailable")),
10654        )
10655        .unwrap_err();
10656        assert!(error.retain_worktree());
10657        assert!(error.to_string().contains("could not confirm"), "{error}");
10658    }
10659
10660    /// Follow-up deduplication compares a title it computed against the title
10661    /// GitHub stored. If those two transforms can disagree, the check never
10662    /// matches and every review round files another copy of the same issue.
10663    #[test]
10664    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
10665        let repo = repo_for_titles();
10666        for raw in [
10667            "Retry loop spins \u{2014} Retry-After parses to zero",
10668            "plain title",
10669            "  spread   over\nlines  ",
10670            "\u{1F916} Generated with something",
10671            &format!("a \u{2014} {}", "very long title ".repeat(20)),
10672            &"x".repeat(300),
10673            &format!("{} \u{2014} end", "y".repeat(88)),
10674            // Exactly the budget, with two spaceless dashes. The scrub turns
10675            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
10676            // character over budget per dash and a second pass clips again,
10677            // producing a different string. Scrub-then-clip cannot.
10678            &{
10679                let tail = "a\u{2014}b c\u{2014}d";
10680                let pad = Style::default().max_title_chars - tail.chars().count();
10681                format!("{}{tail}", "w".repeat(pad))
10682            },
10683        ] {
10684            let once = repo.clean_title(raw).unwrap();
10685            let twice = repo.clean_title(&once).unwrap();
10686            assert_eq!(once, twice, "not idempotent for {raw:?}");
10687            assert!(
10688                once.chars().count() <= repo.style.max_title_chars,
10689                "over budget: {once:?}"
10690            );
10691            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
10692        }
10693    }
10694
10695    #[test]
10696    fn a_title_with_an_em_dash_survives_as_readable_text() {
10697        let repo = repo_for_titles();
10698        assert_eq!(
10699            "Retry loop spins, Retry-After parses to zero",
10700            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
10701                .unwrap()
10702        );
10703    }
10704
10705    #[test]
10706    fn sh_quote_survives_a_quote() {
10707        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
10708    }
10709
10710    #[test]
10711    fn sh_quote_wraps_a_space() {
10712        assert_eq!(
10713            "'/Applications/My App/spar'",
10714            sh_quote("/Applications/My App/spar")
10715        );
10716    }
10717
10718    #[test]
10719    fn finished_states_are_recognised_case_insensitively() {
10720        assert!(is_finished("MERGED"));
10721        assert!(is_finished("closed"));
10722        assert!(!is_finished("OPEN"));
10723        assert!(!is_finished(""));
10724    }
10725
10726    fn state() -> PersistedState {
10727        PersistedState {
10728            version: 1,
10729            checkpoint: 0,
10730            round: 4,
10731            next_actor: "codex".into(),
10732            status: Status::Pending,
10733            pr_head: "abc123".into(),
10734            ledger: Ledger::new(),
10735            filed: vec![],
10736            open_findings: vec![Finding {
10737                severity: Severity::Blocking,
10738                title: "Unchecked error".into(),
10739                detail: "the failure is discarded".into(),
10740                file: "src/a.rs:12".into(),
10741                ..Finding::default()
10742            }],
10743            disputes: vec![Dispute {
10744                title: "Retry limit".into(),
10745                file: "src/net.rs".into(),
10746                reasoning: "the caller already bounds it".into(),
10747            }],
10748            noted: vec![Finding {
10749                severity: Severity::NonBlocking,
10750                title: "Timeout is fixed".into(),
10751                file: "src/config.rs".into(),
10752                ..Finding::default()
10753            }],
10754        }
10755    }
10756
10757    #[test]
10758    fn a_state_comment_round_trips() {
10759        let body = format!(
10760            "{STATE_MARKER}\n{}\n-->",
10761            serde_json::to_string(&state()).unwrap()
10762        );
10763        let back = parse_state_comment(&body).unwrap();
10764        assert_eq!(4, back.round);
10765        assert_eq!("codex", back.next_actor);
10766        assert_eq!("abc123", back.pr_head);
10767        assert_eq!("Unchecked error", back.open_findings[0].title);
10768        assert_eq!("src/net.rs", back.disputes[0].file);
10769        assert_eq!("Timeout is fixed", back.noted[0].title);
10770    }
10771
10772    #[test]
10773    fn old_state_without_new_lists_still_parses() {
10774        let body = format!(
10775            "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
10776             \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
10777        );
10778        let back = parse_state_comment(&body).expect("old state");
10779        assert!(back.open_findings.is_empty());
10780        assert!(back.disputes.is_empty());
10781        assert!(back.noted.is_empty());
10782        assert!(back.pr_head.is_empty());
10783        assert_eq!(0, back.checkpoint);
10784    }
10785
10786    #[test]
10787    fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
10788        let mut local = state();
10789        local.pr_head = "old".into();
10790        local.round = 9;
10791        let mut remote = state();
10792        remote.pr_head = "current".into();
10793        remote.round = 4;
10794
10795        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10796        assert_eq!("current", chosen.pr_head);
10797        assert_eq!(4, chosen.round);
10798    }
10799
10800    #[test]
10801    fn checkpoint_order_breaks_same_round_ties() {
10802        let mut local = state();
10803        local.pr_head = "current".into();
10804        local.round = 4;
10805        local.checkpoint = 8;
10806        let mut remote = local.clone();
10807        remote.checkpoint = 7;
10808        remote.open_findings.clear();
10809
10810        let chosen = choose_state_for_head(vec![local], "current").unwrap();
10811        assert_eq!(8, chosen.checkpoint);
10812
10813        let mut local = state();
10814        local.pr_head = "current".into();
10815        local.round = 4;
10816        local.checkpoint = 8;
10817        let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
10818        assert_eq!(8, chosen.checkpoint);
10819    }
10820
10821    #[test]
10822    fn legacy_same_round_tie_keeps_the_local_checkpoint() {
10823        let mut local = state();
10824        local.pr_head = "current".into();
10825        local.round = 4;
10826        local.open_findings.push(Finding {
10827            title: "local checkpoint".into(),
10828            ..Finding::default()
10829        });
10830        let mut remote = state();
10831        remote.pr_head = "current".into();
10832        remote.round = 4;
10833
10834        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
10835        assert_eq!(
10836            "local checkpoint",
10837            chosen.open_findings.last().unwrap().title
10838        );
10839    }
10840
10841    /// It must render as nothing, so PRs are not littered with machine state.
10842    #[test]
10843    fn the_state_block_is_an_html_comment() {
10844        let body = format!(
10845            "{STATE_MARKER}\n{}\n-->",
10846            serde_json::to_string(&state()).unwrap()
10847        );
10848        assert!(body.starts_with("<!--"));
10849        assert!(body.trim_end().ends_with("-->"));
10850        assert!(!body[..body.find('{').unwrap()].contains("-->"));
10851    }
10852
10853    #[test]
10854    fn an_unrelated_json_block_is_not_state() {
10855        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
10856    }
10857
10858    /// Patching and deleting a comment is not something to do on a substring.
10859    ///
10860    /// A maintainer who notices the block and asks about it writes a comment
10861    /// containing `spar:state`, and the loose filter could not tell theirs from
10862    /// spar's. The next `persist` replaced their words with JSON and
10863    /// `spar clean --pr-state` later deleted it.
10864    #[test]
10865    fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
10866        let block = format!(
10867            "{STATE_MARKER}\n{}\n-->",
10868            serde_json::to_string(&state()).unwrap()
10869        );
10870        assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
10871        assert!(
10872            is_state_comment(&block, "Spar-Bot", "spar-bot"),
10873            "a login differs only in case"
10874        );
10875
10876        assert!(
10877            !is_state_comment(
10878                "why is there a `<!-- spar:state` block on this PR?",
10879                "maintainer",
10880                "spar-bot"
10881            ),
10882            "a question about the block was treated as the block"
10883        );
10884        assert!(
10885            !is_state_comment(&block, "maintainer", "spar-bot"),
10886            "somebody else's comment is not spar's to rewrite"
10887        );
10888        assert!(
10889            !is_state_comment(
10890                &format!("here is what spar writes:\n\n{block}"),
10891                "spar-bot",
10892                "spar-bot"
10893            ),
10894            "the marker has to open the body, not appear in it"
10895        );
10896        assert!(
10897            !is_state_comment(
10898                &format!("{STATE_MARKER}\n{{not json\n-->"),
10899                "spar-bot",
10900                "spar-bot"
10901            ),
10902            "a block that does not parse is not state"
10903        );
10904        assert!(
10905            !is_state_comment(&block, "spar-bot", ""),
10906            "an unknown viewer proves nothing about who wrote it"
10907        );
10908    }
10909
10910    #[test]
10911    fn a_malformed_state_comment_is_none_not_a_panic() {
10912        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
10913    }
10914
10915    #[test]
10916    fn atomic_write_leaves_no_temp_file() {
10917        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
10918        let _ = std::fs::remove_dir_all(&dir);
10919        let path = dir.join("state").join("pr-7.json");
10920        write_json_atomic(&path, &state()).unwrap();
10921        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
10922            .unwrap()
10923            .flatten()
10924            .filter_map(|e| e.file_name().to_str().map(str::to_string))
10925            .collect();
10926        assert_eq!(vec!["pr-7.json".to_string()], files);
10927        let _ = std::fs::remove_dir_all(&dir);
10928    }
10929
10930    #[test]
10931    fn atomic_write_overwrites_rather_than_accumulating() {
10932        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
10933        let _ = std::fs::remove_dir_all(&dir);
10934        let path = dir.join("pr-7.json");
10935        for round in 1..4 {
10936            let mut s = state();
10937            s.round = round;
10938            write_json_atomic(&path, &s).unwrap();
10939        }
10940        let back: PersistedState =
10941            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10942        assert_eq!(3, back.round);
10943        let _ = std::fs::remove_dir_all(&dir);
10944    }
10945
10946    #[test]
10947    fn style_from_env_defaults_to_enforcing() {
10948        std::env::remove_var("SPAR_BAN_EM_DASH");
10949        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
10950        let style = style_from_env();
10951        assert!(style.ban_em_dash && style.ban_ai_attribution);
10952        assert!(
10953            !style.terse,
10954            "the commit filter must not truncate a commit message"
10955        );
10956    }
10957
10958    // -- publishing from a fork --------------------------------------------
10959
10960    /// GitHub's refusal is several lines of advice after the one that says
10961    /// what happened, and spar used to report only the last of them.
10962    #[test]
10963    fn a_push_denial_is_read_from_either_transport() {
10964        let ssh = "ERROR: Permission to example/project.git denied to someone.\n\
10965                   fatal: Could not read from remote repository.\n\n\
10966                   Please make sure you have the correct access rights\n\
10967                   and the repository exists.";
10968        let https = "remote: Permission to example/project.git denied to someone.\n\
10969                     fatal: unable to access 'https://github.com/example/project.git/': The \
10970                     requested URL returned error: 403";
10971        let denied = Some("Permission to example/project.git denied to someone.");
10972        assert_eq!(denied, push_denial(ssh));
10973        assert_eq!(denied, push_denial(https));
10974
10975        let stale = "To github.com:example/project.git\n \
10976                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
10977                     error: failed to push some refs to 'github.com:example/project.git'";
10978        assert_eq!(None, push_denial(stale));
10979
10980        let err = push_error(
10981            &PushTarget::Origin,
10982            "issue-7",
10983            &crate::error::SparError::new(ssh),
10984        );
10985        assert_eq!(
10986            "could not push to origin/issue-7. Permission to example/project.git denied to \
10987             someone.",
10988            err.to_string()
10989        );
10990    }
10991
10992    /// An origin whose hook refuses every push with GitHub's wording, a bare
10993    /// repository standing in for the fork, and a checkout on a new branch
10994    /// with one commit to publish.
10995    #[cfg(unix)]
10996    fn denied_fixture(
10997        tag: &str,
10998        fork_when_denied: bool,
10999    ) -> (ReviewFixture, Repo, PathBuf, PathBuf, PathBuf) {
11000        use std::os::unix::fs::PermissionsExt;
11001        use std::sync::atomic::{AtomicU32, Ordering};
11002        static NEXT: AtomicU32 = AtomicU32::new(0);
11003        let id = NEXT.fetch_add(1, Ordering::Relaxed);
11004        let root =
11005            std::env::temp_dir().join(format!("spar-fork-test-{tag}-{}-{id}", std::process::id()));
11006        let origin = root.join("origin.git");
11007        let fork = root.join("fork.git");
11008        let work = root.join("work");
11009        for dir in [&origin, &fork, &work] {
11010            std::fs::create_dir_all(dir).unwrap();
11011        }
11012        test_git(&origin, &["init", "--bare", "-b", "main"]);
11013        test_git(&fork, &["init", "--bare", "-b", "main"]);
11014        test_git(&work, &["init", "-b", "main"]);
11015        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
11016        test_git(&work, &["config", "user.name", "spar test"]);
11017        test_git(&work, &["config", "commit.gpgsign", "false"]);
11018        std::fs::write(work.join("README.md"), "seed\n").unwrap();
11019        test_git(&work, &["add", "."]);
11020        test_git(&work, &["commit", "-m", "seed"]);
11021        test_git(
11022            &work,
11023            &["remote", "add", "origin", origin.to_str().unwrap()],
11024        );
11025        test_git(&work, &["push", "-q", "origin", "main"]);
11026        // Installed after the seed, so it is only spar's pushes it refuses.
11027        let hook = origin.join("hooks").join("pre-receive");
11028        std::fs::write(
11029            &hook,
11030            "#!/bin/sh\necho 'Permission to example/project.git denied to someone.' >&2\nexit 1\n",
11031        )
11032        .unwrap();
11033        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
11034        test_git(&work, &["checkout", "-q", "-b", "issue-7"]);
11035        std::fs::write(work.join("fix.txt"), "fixed\n").unwrap();
11036        test_git(&work, &["add", "."]);
11037        test_git(&work, &["commit", "-m", "Fix it"]);
11038
11039        let mut text =
11040            String::from("[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n");
11041        if !fork_when_denied {
11042            text.push_str("[loop]\nfork_when_denied = false\n");
11043        }
11044        let cfg = crate::config::parse(&text).unwrap();
11045        let repo = Repo::open(&work, &cfg).unwrap();
11046        // What `fork` would have asked GitHub for, so no test reaches it.
11047        repo.fork
11048            .set(Fork {
11049                owner: "someone".into(),
11050                full_name: "someone/project".into(),
11051                url: fork.to_str().unwrap().into(),
11052            })
11053            .unwrap();
11054        (ReviewFixture { root }, repo, work, origin, fork)
11055    }
11056
11057    #[cfg(unix)]
11058    #[test]
11059    fn a_new_branch_origin_refuses_is_published_from_the_fork() {
11060        let (_fixture, repo, work, origin, fork) = denied_fixture("publish", true);
11061        let first = test_git(&work, &["rev-parse", "HEAD"]);
11062
11063        let target = repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11064
11065        assert_eq!("someone:issue-7", target.head("issue-7"));
11066        assert_eq!(
11067            first.trim(),
11068            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11069        );
11070        assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11071            .trim()
11072            .is_empty());
11073        assert_eq!(
11074            WriteSummary {
11075                attempted: 1,
11076                failed: 0
11077            },
11078            repo.write_summary(),
11079            "a refusal the fork answered is not a failed write"
11080        );
11081
11082        // A later round follows the branch to the fork, leasing what it
11083        // published there.
11084        std::fs::write(work.join("fix.txt"), "fixed again\n").unwrap();
11085        test_git(&work, &["commit", "-qam", "Fix it again"]);
11086        repo.push_to(&work, &target, "issue-7", Some(first.trim()))
11087            .unwrap();
11088        let second = test_git(&work, &["rev-parse", "HEAD"]);
11089        assert_eq!(
11090            second.trim(),
11091            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11092        );
11093    }
11094
11095    /// What a closed pull request leaves on the fork: a branch with nothing
11096    /// a pull request does not hold. Origin's issue branches are rebuilt over
11097    /// in the same case.
11098    #[cfg(unix)]
11099    #[test]
11100    fn a_branch_on_the_fork_with_nothing_of_its_own_is_replaced() {
11101        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-stale", true);
11102        test_git(
11103            &work,
11104            &[
11105                "push",
11106                "-q",
11107                fork.to_str().unwrap(),
11108                "main:refs/heads/issue-7",
11109            ],
11110        );
11111        let head = test_git(&work, &["rev-parse", "HEAD"]);
11112
11113        repo.publish_new_branch(&work, "issue-7", "main").unwrap();
11114
11115        assert_eq!(
11116            head.trim(),
11117            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11118        );
11119        assert_eq!(0, repo.write_summary().failed);
11120    }
11121
11122    #[cfg(unix)]
11123    #[test]
11124    fn work_only_the_fork_has_is_not_published_over() {
11125        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-taken", true);
11126        test_git(&work, &["checkout", "-q", "-b", "elsewhere", "main"]);
11127        std::fs::write(work.join("other.txt"), "somebody's work\n").unwrap();
11128        test_git(&work, &["add", "."]);
11129        test_git(&work, &["commit", "-qm", "Somebody's work"]);
11130        test_git(
11131            &work,
11132            &[
11133                "push",
11134                "-q",
11135                fork.to_str().unwrap(),
11136                "elsewhere:refs/heads/issue-7",
11137            ],
11138        );
11139        let theirs = test_git(&work, &["rev-parse", "elsewhere"]);
11140        test_git(&work, &["checkout", "-q", "issue-7"]);
11141
11142        let err = repo
11143            .publish_new_branch(&work, "issue-7", "main")
11144            .unwrap_err()
11145            .to_string();
11146
11147        assert!(
11148            err.contains(
11149                "the fork someone/project already has issue-7 with 1 commit(s) that are not on main"
11150            ),
11151            "{err}"
11152        );
11153        assert!(err.contains("--delete issue-7"), "{err}");
11154        assert_eq!(
11155            theirs.trim(),
11156            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11157        );
11158        assert_eq!(
11159            WriteSummary {
11160                attempted: 1,
11161                failed: 1
11162            },
11163            repo.write_summary()
11164        );
11165    }
11166
11167    /// A lease that loses says which ref and why, not git's closing line.
11168    #[test]
11169    fn a_rejected_push_names_the_ref_it_rejected() {
11170        let stale = "To github.com:example/project.git\n \
11171                     ! [rejected]        HEAD -> issue-7 (stale info)\n\
11172                     error: failed to push some refs to 'github.com:example/project.git'";
11173
11174        let err = push_error(
11175            &PushTarget::Origin,
11176            "issue-7",
11177            &crate::error::SparError::new(stale),
11178        )
11179        .to_string();
11180
11181        assert!(
11182            err.starts_with(
11183                "could not push to origin/issue-7. [rejected]        HEAD -> issue-7 (stale info)"
11184            ),
11185            "{err}"
11186        );
11187    }
11188
11189    #[test]
11190    fn a_fork_is_reached_by_whatever_reaches_origin() {
11191        let cases = [
11192            (
11193                "git@github.com:example/project.git",
11194                Some("git@github.com:someone/project.git"),
11195            ),
11196            // No user: ~/.ssh/config supplies it.
11197            (
11198                "github.com:example/project.git",
11199                Some("github.com:someone/project.git"),
11200            ),
11201            // A host alias, how one machine pushes as two accounts.
11202            (
11203                "git@github-work:example/project.git",
11204                Some("git@github-work:someone/project.git"),
11205            ),
11206            // SSH over 443, where 22 is blocked.
11207            (
11208                "ssh://git@ssh.github.com:443/example/project.git",
11209                Some("ssh://git@ssh.github.com:443/someone/project.git"),
11210            ),
11211            (
11212                "https://github.com/example/project",
11213                Some("https://github.com/someone/project"),
11214            ),
11215            (
11216                "https://ghe.example.com/scm/example/project.git/",
11217                Some("https://ghe.example.com/scm/someone/project.git"),
11218            ),
11219            (
11220                "git@host:/srv/example/project.git",
11221                Some("git@host:/srv/someone/project.git"),
11222            ),
11223            ("/srv/git/project.git", None),
11224            ("https://github.com/project", None),
11225            ("", None),
11226        ];
11227        for (origin, want) in cases {
11228            assert_eq!(
11229                want.map(str::to_string),
11230                fork_url(origin, "someone/project"),
11231                "{origin}"
11232            );
11233        }
11234    }
11235
11236    #[test]
11237    fn a_fork_found_or_made_is_reached_the_way_origin_is() {
11238        let made = r#"{"full_name":"someone/project","owner":{"login":"someone"},
11239            "ssh_url":"git@github.com:someone/project.git",
11240            "clone_url":"https://github.com/someone/project.git","fork":true}"#;
11241        let listed = r#"[{"nameWithOwner":"someone/project","owner":{"login":"someone"},
11242            "sshUrl":"git@github.com:someone/project.git",
11243            "url":"https://github.com/someone/project"}]"#;
11244
11245        let fork = fork_from_api(made, "git@github-work:example/project.git\n").unwrap();
11246        assert_eq!("git@github-work:someone/project.git", fork.url);
11247        assert_eq!("someone", fork.owner);
11248        assert_eq!("someone/project", fork.full_name);
11249        let fork = fork_from_listing(listed, "ssh://git@ssh.github.com:443/example/project.git")
11250            .unwrap()
11251            .unwrap();
11252        assert_eq!("ssh://git@ssh.github.com:443/someone/project.git", fork.url);
11253        assert_eq!(
11254            None,
11255            fork_from_listing("[]", "git@github.com:a/b.git").unwrap()
11256        );
11257
11258        // An origin with no owner/repo path falls back to GitHub's URL, the
11259        // SSH one when origin looks like SSH.
11260        let fork = fork_from_api(made, "https://github.com/project").unwrap();
11261        assert_eq!("https://github.com/someone/project.git", fork.url);
11262        let fork = fork_from_api(made, "ssh://git@github.com/project").unwrap();
11263        assert_eq!("git@github.com:someone/project.git", fork.url);
11264
11265        assert_eq!("someone:issue-7", PushTarget::Fork(fork).head("issue-7"));
11266        assert_eq!("issue-7", PushTarget::Origin.head("issue-7"));
11267    }
11268
11269    #[test]
11270    fn only_the_viewers_own_fork_can_be_pushed_to() {
11271        let pr = |owner: &str, name: &str| PrView {
11272            number: 9,
11273            url: String::new(),
11274            title: String::new(),
11275            body: String::new(),
11276            head_ref_name: "issue-7".into(),
11277            base_ref_name: "main".into(),
11278            state: "OPEN".into(),
11279            closing_issues_references: Vec::new(),
11280            is_cross_repository: true,
11281            head_repository_owner: Some(crate::model::Author {
11282                login: owner.into(),
11283            }),
11284            head_repository: Some(crate::model::HeadRepository { name: name.into() }),
11285        };
11286        let origin = "git@github.com:example/project.git";
11287
11288        assert_eq!(
11289            Some(PushTarget::Fork(Fork {
11290                owner: "Someone".into(),
11291                full_name: "Someone/project".into(),
11292                url: "git@github.com:Someone/project.git".into(),
11293            })),
11294            own_fork_target(&pr("Someone", "project"), "someone", origin)
11295        );
11296        assert_eq!(
11297            None,
11298            own_fork_target(&pr("other", "project"), "someone", origin)
11299        );
11300        assert_eq!(None, own_fork_target(&pr("someone", ""), "someone", origin));
11301        assert_eq!(
11302            None,
11303            own_fork_target(&pr("someone", "project"), "someone", "/srv/x")
11304        );
11305        let mut unnamed = pr("someone", "project");
11306        unnamed.head_repository_owner = None;
11307        assert_eq!(None, own_fork_target(&unnamed, "someone", origin));
11308    }
11309
11310    /// `spar resume` and `spar checkin` on a pull request from the user's own
11311    /// fork check its branch out from the fork and push back there.
11312    #[cfg(unix)]
11313    #[test]
11314    fn a_pull_request_on_the_viewers_fork_is_checked_out_and_pushed_there() {
11315        let (_fixture, repo, work, origin, fork) = denied_fixture("fork-resume", true);
11316        test_git(
11317            &work,
11318            &[
11319                "push",
11320                "-q",
11321                fork.to_str().unwrap(),
11322                "issue-7:refs/heads/issue-7",
11323            ],
11324        );
11325        let published = test_git(&work, &["rev-parse", "issue-7"]);
11326        test_git(&work, &["checkout", "-q", "main"]);
11327        let target = PushTarget::Fork(repo.fork.get().unwrap().clone());
11328        let pr = PrView {
11329            number: 31,
11330            url: String::new(),
11331            title: String::new(),
11332            body: String::new(),
11333            head_ref_name: "issue-7".into(),
11334            base_ref_name: "main".into(),
11335            state: "OPEN".into(),
11336            closing_issues_references: Vec::new(),
11337            is_cross_repository: true,
11338            head_repository_owner: None,
11339            head_repository: None,
11340        };
11341
11342        let (path, branch) = repo.worktree_for_pr(&pr, &target).unwrap();
11343
11344        assert_eq!("issue-7", branch);
11345        assert_eq!(
11346            published.trim(),
11347            test_git(&path, &["rev-parse", "HEAD"]).trim()
11348        );
11349        std::fs::write(path.join("fix.txt"), "answered a comment\n").unwrap();
11350        test_git(&path, &["commit", "-qam", "Answer a comment"]);
11351        repo.push_to(&path, &target, &branch, Some(published.trim()))
11352            .unwrap();
11353        assert_eq!(
11354            test_git(&path, &["rev-parse", "HEAD"]).trim(),
11355            test_git(&fork, &["rev-parse", "issue-7"]).trim()
11356        );
11357        assert!(test_git(&origin, &["branch", "--list", "issue-7"])
11358            .trim()
11359            .is_empty());
11360    }
11361
11362    #[cfg(unix)]
11363    #[test]
11364    fn with_forking_off_a_refused_push_says_why_and_how() {
11365        let (_fixture, repo, work, _origin, fork) = denied_fixture("fork-off", false);
11366
11367        let err = repo
11368            .publish_new_branch(&work, "issue-7", "main")
11369            .unwrap_err()
11370            .to_string();
11371
11372        assert!(
11373            err.contains("Permission to example/project.git denied to someone."),
11374            "{err}"
11375        );
11376        assert!(err.contains("fork_when_denied = true"), "{err}");
11377        assert!(test_git(&fork, &["branch", "--list", "issue-7"])
11378            .trim()
11379            .is_empty());
11380        assert_eq!(
11381            WriteSummary {
11382                attempted: 1,
11383                failed: 1
11384            },
11385            repo.write_summary()
11386        );
11387    }
11388}
11389
11390#[cfg(test)]
11391mod comment_page_tests {
11392    use super::*;
11393
11394    #[test]
11395    fn a_single_merged_array_is_read() {
11396        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
11397        assert_eq!(2, pages.len());
11398        assert_eq!(Some(2), pages[1]["id"].as_i64());
11399    }
11400
11401    #[test]
11402    fn concatenated_pages_from_an_older_gh_are_read_too() {
11403        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
11404        assert_eq!(2, pages.len());
11405    }
11406
11407    /// A comment body containing a bracket pair used to split the payload into
11408    /// two invalid halves, so no state comment was found and a resume silently
11409    /// started from round one.
11410    #[test]
11411    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
11412        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
11413        let pages = parse_comment_pages(text);
11414        assert_eq!(2, pages.len(), "{pages:?}");
11415        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
11416    }
11417
11418    #[test]
11419    fn empty_output_is_no_comments_not_a_panic() {
11420        assert!(parse_comment_pages("").is_empty());
11421        assert!(parse_comment_pages("   ").is_empty());
11422        assert!(parse_comment_pages("[]").is_empty());
11423    }
11424
11425    #[test]
11426    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
11427        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
11428    }
11429
11430    #[test]
11431    fn a_write_postcheck_rejects_truncated_comment_pages() {
11432        let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
11433        assert!(
11434            error.to_string().contains("unexpected comment pages"),
11435            "{error}"
11436        );
11437    }
11438
11439    #[test]
11440    fn a_write_postcheck_rejects_empty_or_non_array_output() {
11441        assert!(try_parse_comment_pages("").is_err());
11442        assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
11443        assert!(try_parse_comment_pages("[]").is_ok());
11444    }
11445
11446    #[test]
11447    fn state_is_found_in_the_last_matching_comment() {
11448        let payload = |round: u32| {
11449            format!(
11450                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
11451            )
11452        };
11453        let text = serde_json::to_string(&serde_json::json!([
11454            {"id": 1, "body": payload(1)},
11455            {"id": 2, "body": "looks good to me"},
11456            {"id": 3, "body": payload(5)},
11457        ]))
11458        .unwrap();
11459        let pages = parse_comment_pages(&text);
11460        let last = pages
11461            .iter()
11462            .rev()
11463            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
11464            .unwrap();
11465        assert_eq!(5, last.round);
11466    }
11467}
11468
11469#[cfg(test)]
11470mod linked_pr_tests {
11471    use super::*;
11472
11473    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
11474    /// It carries an id and a whole repository object per linked issue, and a
11475    /// parser that chokes on those reports "no pull request", which is the one
11476    /// answer that makes spar implement over the top of somebody's work.
11477    const REAL_PAYLOAD: &str = r#"[
11478      {"number":14252,"title":"fix: reject leading-dash branch names",
11479       "url":"https://github.com/cli/cli/pull/14252",
11480       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
11481         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11482         "url":"https://github.com/cli/cli/issues/14238"}]},
11483      {"number":14217,"title":"another change",
11484       "url":"https://github.com/cli/cli/pull/14217",
11485       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
11486         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
11487         "url":"https://github.com/cli/cli/issues/9761"}]},
11488      {"number":14200,"title":"unlinked work",
11489       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
11490    ]"#;
11491
11492    #[test]
11493    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
11494        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
11495        assert_eq!(14252, pr.number);
11496        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
11497    }
11498
11499    #[test]
11500    fn the_right_pr_is_picked_out_of_several() {
11501        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
11502    }
11503
11504    #[test]
11505    fn an_issue_nobody_is_working_on_finds_nothing() {
11506        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
11507    }
11508
11509    #[test]
11510    fn an_unlinked_pr_is_never_matched() {
11511        // 14200 closes nothing, so no issue number should ever return it.
11512        for issue in [14200, 0, 1] {
11513            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
11514                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
11515            }
11516        }
11517    }
11518
11519    #[test]
11520    fn empty_or_broken_output_is_none_rather_than_a_panic() {
11521        assert!(find_linked_pr("", 1).is_none());
11522        assert!(find_linked_pr("[]", 1).is_none());
11523        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
11524        assert!(find_linked_pr("[{\"number\":", 1).is_none());
11525    }
11526
11527    /// "gh could not answer" and "nobody is working on it" are different
11528    /// answers, and only one of them means implement from scratch.
11529    ///
11530    /// The parse used to fold both into `None`, so an authentication failure or
11531    /// a rate limit read as an empty list and spar opened a second pull request
11532    /// for an issue somebody already had work open on.
11533    #[test]
11534    fn an_unreadable_list_is_not_an_empty_list() {
11535        assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
11536        assert!(linked_pr_rows("[{\"number\":").is_none());
11537        assert!(linked_pr_rows("").is_none());
11538
11539        let empty = linked_pr_rows("[]").expect("an empty list is an answer");
11540        assert!(empty.is_empty());
11541        assert!(pick_linked_pr(&empty, 14238).is_none());
11542
11543        let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
11544        assert_eq!(3, listed.len());
11545        assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
11546    }
11547
11548    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
11549    #[test]
11550    fn pr_view_reads_the_cross_repository_flag() {
11551        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
11552                       "baseRefName":"main","state":"OPEN",
11553                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
11554        let pr: PrView = serde_json::from_str(json).unwrap();
11555        assert!(pr.is_cross_repository);
11556        assert!(pr.is_open());
11557
11558        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
11559        assert!(
11560            !serde_json::from_str::<PrView>(&same_repo)
11561                .unwrap()
11562                .is_cross_repository
11563        );
11564    }
11565}
11566
11567#[cfg(test)]
11568mod min_number_tests {
11569    /// The floor is applied before the cap, which is the order that matters.
11570    /// spar takes the *lowest* numbered open items, so a repository with a tail
11571    /// of old issues would otherwise spend its whole run in the tail: the cap
11572    /// would be filled by the oldest items and the floor would never be
11573    /// reached. Filtering first is what makes the setting do anything.
11574    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
11575        let mut numbers: Vec<i64> = open.to_vec();
11576        numbers.sort_unstable();
11577        if min_number > 0 {
11578            numbers.retain(|n| *n >= min_number);
11579        }
11580        numbers.truncate(limit);
11581        numbers
11582    }
11583
11584    #[test]
11585    fn the_floor_is_applied_before_the_cap_not_after() {
11586        let open = [12, 13, 14, 480, 481, 482];
11587        assert_eq!(vec![480, 481], pick(&open, 2, 480));
11588        // Capping first would have returned the two oldest and then filtered
11589        // them all away, leaving nothing.
11590        assert!(!pick(&open, 2, 480).is_empty());
11591    }
11592
11593    #[test]
11594    fn no_floor_keeps_the_old_behaviour() {
11595        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
11596    }
11597
11598    #[test]
11599    fn the_floor_is_inclusive() {
11600        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
11601    }
11602
11603    #[test]
11604    fn a_floor_above_everything_open_yields_nothing() {
11605        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
11606    }
11607}
11608
11609#[cfg(test)]
11610mod review_record_tests {
11611    use super::*;
11612    use serde_json::json;
11613
11614    #[test]
11615    fn prune_state_knows_which_files_belong_to_a_pull_request() {
11616        assert_eq!(Some(12), state_file_number("pr-12.json"));
11617        assert_eq!(Some(12), state_file_number("review-12.json"));
11618        assert_eq!(Some(12), state_file_number("checkin-12.json"));
11619        for shared in [
11620            "spend.json",
11621            "implementors.json",
11622            "contested.json",
11623            "listen.json",
11624            "pr-.json",
11625            "pr-12.json.tmp",
11626            "pr-x.json",
11627        ] {
11628            assert_eq!(None, state_file_number(shared), "{shared}");
11629        }
11630    }
11631
11632    #[test]
11633    fn a_review_the_viewer_already_submitted_with_the_same_body_is_not_sent_again() {
11634        let reviews = vec![
11635            json!({"user": {"login": "Me"}, "body": "the body", "commit_id": "AAA", "state": "APPROVED"}),
11636            json!({"user": {"login": "alice"}, "body": "other", "commit_id": "aaa", "state": "COMMENTED"}),
11637        ];
11638        assert!(has_viewer_review(&reviews, "me", "the body", "aaa"));
11639        assert!(!has_viewer_review(&reviews, "me", "other", "aaa"));
11640        assert!(!has_viewer_review(&reviews, "alice", "the body", "aaa"));
11641    }
11642
11643    /// A clean review says the same thing on every commit. Taking the approval
11644    /// of an older commit for this one left the new head unapproved while the
11645    /// record said it was.
11646    #[test]
11647    fn the_same_body_on_another_commit_or_dismissed_is_a_review_still_to_send() {
11648        let older = vec![
11649            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa", "state": "APPROVED"}),
11650        ];
11651        assert!(!has_viewer_review(&older, "me", "the body", "bbb"));
11652        let dismissed = vec![
11653            json!({"user": {"login": "me"}, "body": "the body", "commit_id": "bbb", "state": "DISMISSED"}),
11654        ];
11655        assert!(!has_viewer_review(&dismissed, "me", "the body", "bbb"));
11656    }
11657
11658    #[test]
11659    fn a_response_splits_into_status_headers_and_body() {
11660        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";
11661        let resp = parse_api_response(text).expect("a response");
11662        assert_eq!(200, resp.status);
11663        assert_eq!(Some(&"60".to_string()), resp.headers.get("x-poll-interval"));
11664        assert_eq!(
11665            Some(&"Tue, 20 Oct 2026 10:00:00 GMT".to_string()),
11666            resp.headers.get("last-modified")
11667        );
11668        assert_eq!("[{\"id\": \"1\"}]\n", resp.body);
11669
11670        let not_modified =
11671            parse_api_response("HTTP/2.0 304 Not Modified\nX-Poll-Interval: 60\n\n").unwrap();
11672        assert_eq!(304, not_modified.status);
11673        assert!(not_modified.body.is_empty());
11674
11675        assert_eq!(None, parse_api_response(""));
11676        assert_eq!(None, parse_api_response("gh: command not found\n"));
11677    }
11678
11679    #[test]
11680    fn review_requests_are_read_off_the_timeline_with_who_and_when() {
11681        let text = json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": [
11682            {"createdAt": "2026-10-20T10:00:00Z", "requestedReviewer": {"login": "viewer"}},
11683            {"createdAt": "2026-10-20T11:00:00Z", "requestedReviewer": {"combinedSlug": "example/ours"}},
11684            {"createdAt": "2026-10-20T12:00:00Z", "requestedReviewer": null},
11685            {}
11686        ]}}}}})
11687        .to_string();
11688        let made = parse_review_requests(&text).expect("requests");
11689        assert_eq!(2, made.len());
11690        assert_eq!("2026-10-20T10:00:00Z", made[0].at);
11691        assert_eq!("viewer", made[0].who.login);
11692        assert!(made[0].who.slug.is_empty());
11693        assert_eq!("2026-10-20T11:00:00Z", made[1].at);
11694        assert!(made[1].who.login.is_empty());
11695        assert_eq!("example/ours", made[1].who.slug);
11696
11697        let none =
11698            json!({"data": {"repository": {"pullRequest": {"timelineItems": {"nodes": []}}}}});
11699        assert_eq!(
11700            Some(0),
11701            parse_review_requests(&none.to_string()).map(|m| m.len())
11702        );
11703        assert!(parse_review_requests(r#"{"errors": [{"message": "no"}]}"#).is_none());
11704        assert!(parse_review_requests("gh: not json").is_none());
11705    }
11706
11707    #[test]
11708    fn a_failed_review_submission_that_landed_is_reconciled_as_success() {
11709        let landed = vec![json!({"user": {"login": "me"}, "body": "the body", "commit_id": "aaa"})];
11710        let error = crate::error::SparError::call_failed("gh: boom");
11711        assert!(
11712            reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(landed)).is_ok()
11713        );
11714
11715        let missing = vec![json!({"user": {"login": "me"}, "body": "older", "commit_id": "aaa"})];
11716        let err = reconcile_review_post(5, "the body", "me", "aaa", error.clone(), Ok(missing))
11717            .unwrap_err();
11718        assert_eq!(crate::error::ErrorKind::CallFailed, err.kind());
11719
11720        let unreadable = Err(crate::error::SparError::new("no network"));
11721        let err = reconcile_review_post(5, "the body", "me", "aaa", error, unreadable).unwrap_err();
11722        assert_eq!(crate::error::ErrorKind::UncertainWrite, err.kind());
11723        assert!(err.to_string().contains("could not be verified"), "{err}");
11724    }
11725    #[test]
11726    fn a_review_with_line_comments_names_the_new_side_and_a_range_only_when_it_has_one() {
11727        let comments = vec![
11728            InlineComment {
11729                path: "src/a.rs".into(),
11730                line: 12,
11731                start_line: None,
11732                body: "one".into(),
11733            },
11734            InlineComment {
11735                path: "src/b.rs".into(),
11736                line: 9,
11737                start_line: Some(4),
11738                body: "two".into(),
11739            },
11740        ];
11741        assert_eq!(
11742            json!({
11743                "commit_id": "abc",
11744                "event": "COMMENT",
11745                "body": "the body",
11746                "comments": [
11747                    {"path": "src/a.rs", "line": 12, "side": "RIGHT", "body": "one"},
11748                    {"path": "src/b.rs", "line": 9, "side": "RIGHT", "start_line": 4,
11749                     "start_side": "RIGHT", "body": "two"},
11750                ],
11751            }),
11752            review_payload("abc", "COMMENT", "the body", &comments)
11753        );
11754    }
11755
11756    #[test]
11757    fn the_new_side_of_each_hunk_is_read_and_its_lines_are_never_taken_for_headers() {
11758        let diff = r#"diff --git a/src/a.rs b/src/a.rs
11759index 1111111..2222222 100644
11760--- a/src/a.rs
11761+++ b/src/a.rs
11762@@ -1,3 +1,4 @@ fn x
11763 one
11764+++ an added line that looks like a header
11765 two
11766 three
11767@@ -20 +21,2 @@
11768-old
11769+new
11770+more
11771@@ -30,1 +32,0 @@
11772-gone
11773diff --git a/gone.rs b/gone.rs
11774deleted file mode 100644
11775--- a/gone.rs
11776+++ /dev/null
11777@@ -1,2 +0,0 @@
11778-a
11779-b
11780diff --git a/old.rs b/renamed.rs
11781similarity index 90%
11782rename from old.rs
11783rename to renamed.rs
11784--- a/old.rs
11785+++ b/renamed.rs
11786@@ -5,0 +6,1 @@
11787+inserted
11788diff --git a/with space.rs b/with space.rs
11789--- a/with space.rs	
11790+++ b/with space.rs	
11791@@ -2 +2 @@
11792-x
11793+y
11794diff --git "a/sp\tace.rs" "b/sp\tace.rs"
11795--- "a/sp\tace.rs"
11796+++ "b/sp\tace.rs"
11797@@ -1 +1 @@
11798-x
11799+y
11800"#;
11801        let hunks = new_side_hunks(diff);
11802        assert_eq!(
11803            Hunks::from([
11804                ("renamed.rs".to_string(), vec![(6, 6)]),
11805                ("src/a.rs".to_string(), vec![(1, 4), (21, 22)]),
11806                ("with space.rs".to_string(), vec![(2, 2)]),
11807            ]),
11808            hunks
11809        );
11810    }
11811
11812    #[test]
11813    fn only_a_refusal_is_final_enough_to_send_a_review_again() {
11814        let error = |text: &str| crate::error::SparError::new(text);
11815        assert!(refused(&error(
11816            "command failed (1): gh api\ngh: Validation Failed (HTTP 422)"
11817        )));
11818        assert!(!refused(&error("gh: Bad Gateway (HTTP 502)")));
11819        assert!(!refused(&error("gh: HTTP 504: Gateway Timeout")));
11820        assert!(!refused(&error("command timed out after 300s")));
11821    }
11822
11823    #[test]
11824    fn a_hunk_header_with_its_counts_left_out_counts_one() {
11825        assert_eq!(Some(((20, 1), (21, 2))), hunk_header("@@ -20 +21,2 @@"));
11826        assert_eq!(Some(((1, 3), (1, 1))), hunk_header("@@ -1,3 +1 @@ fn x"));
11827        assert_eq!(None, hunk_header("@@@ -1,3 -1,3 +1,4 @@@"));
11828        assert_eq!(None, hunk_header(" @@ -1 +1 @@"));
11829    }
11830}