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::{Config, Drafts, Followups, StateStore};
18use crate::error::Result;
19use crate::model::{
20    Contested, ContestedItem, Followup, Issue, IssueRef, ItemKind, PersistedState, PrRef, PrRow,
21    PrView,
22};
23use crate::proc::{self, ExecOpts};
24use crate::style::{self, Style};
25use crate::textsim;
26use crate::{bail, logdim, logwarn, spar_err};
27
28/// gh returns newest first, so its `--limit` cannot be used to take the lowest
29/// numbered items: it would slice the newest N and then sorting that slice
30/// silently drops the older ones. Fetch a generous page, sort, then truncate.
31pub const FETCH_CEILING: usize = 500;
32
33/// An unclosed HTML comment on purpose. The payload is written after it and
34/// terminated with `-->`, so GitHub renders the whole block as nothing.
35pub const STATE_MARKER: &str = "<!-- spar:state";
36
37/// The signature on a comment spar composed, invisible when rendered.
38///
39/// spar posts as whoever ran it, so a comment from the viewer proves nothing
40/// about who wrote it. Without this, spar's own outcome and round comments
41/// counted as the viewer having answered, and `checkin` skipped a person's
42/// comment as "replied to since" because a bookkeeping comment happened to be
43/// newer than it.
44pub const COMMENT_MARKER: &str = "<!-- spar:comment -->";
45
46/// An entry boundary in the local follow-up note, on the same principle as
47/// `STATE_MARKER` and rendered as nothing for the same reason.
48///
49/// A follow-up's own sections are written as `## Problem` and friends, at the
50/// same heading level as the entry's title, so the file's shape does not say
51/// which of two `## ` lines starts an entry. This does. Files written before it
52/// existed are still read, by the heuristic in `followups::parse`.
53pub const FOLLOWUP_MARKER: &str = "<!-- spar:followup -->";
54
55const WORKTREE_DIR: &str = ".spar-worktrees";
56const STATE_DIR: &str = ".spar";
57
58/// Files SPAR writes into a worktree itself, which no agent ever authors.
59pub(crate) const RECOVERY_MARKER_PREFIX: &str = ".spar-recovery-needed-";
60pub(crate) const EDITED_GIT_MARKER: &str = ".spar-edited-git-marker";
61
62/// What SPAR is allowed to throw away when it decides a checkout can go.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub(crate) enum Disposable {
65    /// Recognized build output, and nothing else. Somebody may have put the
66    /// rest there and want it back.
67    BuildOutput,
68    /// Every ignored file, and SPAR's own markers. Only rebuilding a review
69    /// checkout qualifies, and only because the next statement replaces it with
70    /// a fresh checkout of the same ref: what is there came from an earlier
71    /// `spar review` of the same pull request, and `git worktree add` would
72    /// write over it regardless. Refusing instead left the command unusable on
73    /// any project whose build writes outside `target/`, since a single
74    /// lockfile beside a generator's manifest blocked every later run.
75    ReviewOutput,
76}
77
78/// Whether `path` is one of the files SPAR leaves in a worktree root.
79///
80/// A marker is evidence for a person, not something a call did to the tree, and
81/// two reviewers share one checkout: the marker written when the first one's
82/// answer was refused is otherwise a brand new untracked file in the snapshot
83/// the second one is compared against, so one refusal took the other review
84/// down with it.
85pub(crate) fn is_spar_marker(path: &Path) -> bool {
86    let mut components = path.components();
87    let Some(std::path::Component::Normal(name)) = components.next() else {
88        return false;
89    };
90    if components.next().is_some() {
91        return false;
92    }
93    let Some(name) = name.to_str() else {
94        return false;
95    };
96    name == EDITED_GIT_MARKER || name.starts_with(RECOVERY_MARKER_PREFIX)
97}
98
99/// Whether `path` is somewhere SPAR writes in the repository it is driving.
100///
101/// The markers are one part of it. The other is the run state under `.spar/`,
102/// which SPAR rewrites while a call it started is still running: recording the
103/// branches it created during a parallel triage is enough to make both agents
104/// look like they changed the tree they were told only to read. SPAR's own
105/// bookkeeping is not evidence about an agent, so it never enters the snapshot
106/// a call is compared against.
107///
108/// `.spar-worktrees/` is deliberately not here. A resident worktree enters the
109/// snapshot as one ignored entry so that another run's churn inside it does not
110/// disturb this baseline, while deleting the whole checkout still does.
111pub(crate) fn is_spar_own_path(path: &Path) -> bool {
112    if is_spar_marker(path) {
113        return true;
114    }
115    let Some(std::path::Component::Normal(name)) = path.components().next() else {
116        return false;
117    };
118    name == OsStr::new(STATE_DIR)
119}
120
121/// How many names one part of a split may be tried on before giving up. High
122/// enough that nobody reaches it by splitting the same pull request again, low
123/// enough that a repository where every name is taken says so rather than
124/// looping.
125const SPLIT_SLOTS: u32 = 20;
126
127#[derive(Debug, Clone)]
128pub struct SplitPushError {
129    message: String,
130    retain_worktree: bool,
131}
132
133impl SplitPushError {
134    pub(crate) fn new(message: impl Into<String>, retain_worktree: bool) -> Self {
135        Self {
136            message: message.into(),
137            retain_worktree,
138        }
139    }
140
141    pub fn retain_worktree(&self) -> bool {
142        self.retain_worktree
143    }
144}
145
146impl std::fmt::Display for SplitPushError {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        f.write_str(&self.message)
149    }
150}
151
152impl std::error::Error for SplitPushError {}
153
154/// The branch and worktree name for one part, on its `attempt`th name.
155///
156/// The unsuffixed name first, so the ordinary case reads as `split-12-1` and
157/// only a repeat split carries a suffix.
158fn split_slot(parent: i64, index: usize, attempt: u32) -> String {
159    match attempt {
160        1 => format!("split-{parent}-{index}"),
161        n => format!("split-{parent}-{index}-{n}"),
162    }
163}
164
165#[derive(Debug)]
166pub struct Repo {
167    root: PathBuf,
168    pub style: Style,
169    pub branch_prefix: String,
170    pub state_store: StateStore,
171    pub followups: Followups,
172    pub drafts: Drafts,
173    /// The login `gh` is authenticated as, asked at most once.
174    ///
175    /// `OnceLock` rather than `OnceCell` because `&Repo` crosses a
176    /// `std::thread::scope` whenever both agents are asked at the same time,
177    /// and only `OnceLock` is `Sync`.
178    viewer: OnceLock<String>,
179    /// Highest persisted checkpoint observed for each pull request.
180    ///
181    /// Kept in memory so a transient state read cannot reset the sequence
182    /// after a resume already loaded a newer checkpoint.
183    checkpoints: Mutex<BTreeMap<i64, u64>>,
184    writes: WriteStats,
185}
186
187#[derive(Debug, Default)]
188struct WriteStats {
189    attempted: AtomicUsize,
190    failed: AtomicUsize,
191}
192
193#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
194pub(crate) struct WriteSummary {
195    pub(crate) attempted: usize,
196    pub(crate) failed: usize,
197}
198
199impl WriteSummary {
200    pub(crate) fn succeeded(self) -> usize {
201        self.attempted.saturating_sub(self.failed)
202    }
203}
204
205/// Ignored, untracked paths present before an editing call starts.
206///
207/// Existing build artifacts are deliberately part of the baseline. Callers can
208/// therefore distinguish them from an ignored file the editing call created
209/// and avoid deleting the latter as if no work had happened.
210#[derive(Debug, Clone)]
211pub(crate) struct WorktreeBaseline {
212    attributes: AttributeState,
213    ignored_untracked: IgnoredState,
214    git_state: GitState,
215}
216
217/// The recorded Git state of a worktree that must remain read only.
218///
219/// Read-only agent calls are still ordinary processes. Capturing the complete
220/// working state lets callers refuse to publish their answer or delete the
221/// checkout if a call writes despite its instructions.
222#[derive(Debug, Clone)]
223pub(crate) struct WorktreeCheckpoint {
224    path: PathBuf,
225    attributes: AttributeState,
226    git_state: GitState,
227    ignored_untracked: IgnoredState,
228}
229
230#[derive(Debug, Clone, Default, PartialEq, Eq)]
231pub(crate) struct AttributeState {
232    files: BTreeMap<PathBuf, [u8; 32]>,
233}
234
235/// Exact paths and fingerprints for every untracked file, plus ignored paths.
236///
237/// Paths stay as operating-system strings so a non-UTF-8 filename cannot be
238/// merged with another path by lossy command-output conversion.
239#[derive(Debug, Clone, Default, PartialEq, Eq)]
240pub(crate) struct IgnoredState {
241    files: BTreeMap<PathBuf, UntrackedFile>,
242    ignored: BTreeSet<PathBuf>,
243}
244
245/// A bounded-cost fingerprint for an untracked filesystem entry.
246///
247/// Content hashing every ignored compiler artifact made each checkpoint read
248/// gigabytes. File identity, type, size, timestamps, and mode detect ordinary
249/// writes without rereading build output. Unix change time also changes when a
250/// writer restores the modification time.
251#[derive(Debug, Clone, PartialEq, Eq)]
252struct UntrackedFile {
253    kind: u8,
254    len: u64,
255    modified: Option<std::time::SystemTime>,
256    created: Option<std::time::SystemTime>,
257    readonly: bool,
258    symlink_target: Option<Vec<u8>>,
259    #[cfg(unix)]
260    device: u64,
261    #[cfg(unix)]
262    inode: u64,
263    #[cfg(unix)]
264    mode: u32,
265    #[cfg(unix)]
266    change_seconds: i64,
267    #[cfg(unix)]
268    change_nanoseconds: i64,
269}
270
271#[derive(Debug, Clone, Default, PartialEq, Eq)]
272pub(crate) struct GitState {
273    repositories: BTreeMap<PathBuf, RepositoryState>,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
277struct RepositoryState {
278    head: String,
279    unsafe_index_flags: Vec<u8>,
280    tracked: BTreeMap<PathBuf, TrackedEntry>,
281    gitlinks: BTreeMap<PathBuf, String>,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
285struct TrackedEntry {
286    index_mode: String,
287    index_oid: String,
288    worktree: Option<WorktreeFile>,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
292struct WorktreeFile {
293    mode: String,
294    #[cfg(unix)]
295    permissions: u32,
296    raw_oid: String,
297    fingerprint: [u8; 32],
298    content: [u8; 32],
299}
300
301struct Gitlink {
302    path: PathBuf,
303    oid: String,
304}
305
306struct IndexEntry {
307    path: PathBuf,
308    mode: String,
309    oid: String,
310}
311
312impl IgnoredState {
313    fn is_ignored(&self, path: &Path) -> bool {
314        self.ignored.contains(path)
315    }
316
317    fn changed_paths(&self, after: &Self) -> Vec<PathBuf> {
318        let mut paths: BTreeSet<PathBuf> = self.files.keys().cloned().collect();
319        paths.extend(after.files.keys().cloned());
320        paths
321            .into_iter()
322            .filter(|path| self.files.get(path) != after.files.get(path))
323            .collect()
324    }
325
326    fn changed_existing_paths(&self, after: &Self) -> Vec<PathBuf> {
327        self.files
328            .iter()
329            .filter(|(path, state)| after.files.get(*path) != Some(*state))
330            .map(|(path, _)| path.clone())
331            .collect()
332    }
333
334    fn new_ordinary_paths(&self, after: &Self) -> Vec<PathBuf> {
335        after
336            .files
337            .keys()
338            .filter(|path| !after.is_ignored(path) && !self.files.contains_key(*path))
339            .cloned()
340            .collect()
341    }
342
343    /// Whether anything moved here that is not recognized build or cache
344    /// output.
345    ///
346    /// A read-only call is held to its word by comparing the worktree before
347    /// and after. Verifying a finding usually means building the project and
348    /// running its tests, which rewrites that output, and holding a reviewer to
349    /// a byte-identical `dist/` threw away the review it had just done. What
350    /// the call was asked to judge is the tracked tree, and that is compared as
351    /// strictly as ever, along with every other untracked file.
352    pub(crate) fn changed_beyond_generated(&self, after: &Self) -> bool {
353        let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
354        paths.extend(after.files.keys());
355        paths.into_iter().any(|path| {
356            if self.files.get(path) == after.files.get(path)
357                && self.is_ignored(path) == after.is_ignored(path)
358            {
359                return false;
360            }
361            !self.generated_move(after, path)
362        })
363    }
364
365    /// What a read-only call left behind, split by whether it condemns the
366    /// answer.
367    ///
368    /// `changed_beyond_generated` answers this from a list of directory names,
369    /// which is a guess about where a build writes. The repository already
370    /// states where it writes, in its own ignore rules, and a review died over
371    /// the gap: a reviewer ran the project's binding generator to check a
372    /// finding, that wrote a lockfile beside its own manifest rather than under
373    /// `target/`, and both agents lost an hour of work to a path Git had been
374    /// told to disown.
375    ///
376    /// Forgiving only the creation of such a path was not enough, because the
377    /// call is not the only writer. A read-only call runs for minutes in the
378    /// primary checkout, which is a live desk: Finder rewrites `.DS_Store`, an
379    /// editor rewrites its own state, and a triage that asks two agents at once
380    /// compares both of them against that same directory. One ambient write
381    /// discarded two good answers and ended the run with nothing scheduled. So
382    /// a path the project ignores is out of scope however it moved, and what
383    /// remains fatal is what the call was actually asked about: the tracked
384    /// tree, and any ordinary untracked file, because a reviewer was told to
385    /// keep its scratch out of the worktree.
386    ///
387    /// Nothing is silent. A tolerated path that is not recognized build output
388    /// is named by the caller, so an agent that rewrote a person's `.env` is
389    /// reported rather than hidden. That is the trade this makes: the answer of
390    /// a call that never commits is worth more than the guarantee, and the
391    /// warning carries what the guarantee used to.
392    pub(crate) fn read_only_leavings(&self, after: &Self) -> ReadOnlyLeavings {
393        let mut paths: BTreeSet<&PathBuf> = self.files.keys().collect();
394        paths.extend(after.files.keys());
395        let mut leavings = ReadOnlyLeavings::default();
396        for path in paths {
397            if self.files.get(path) == after.files.get(path)
398                && self.is_ignored(path) == after.is_ignored(path)
399            {
400                continue;
401            }
402            if !self.disposable_move(after, path) {
403                leavings.condemning.push(path.clone());
404            } else if !is_generated_artifact(path) {
405                leavings.tolerated.push(path.clone());
406            }
407        }
408        leavings
409    }
410
411    /// Whether what happened at `path` between these two states is a build or
412    /// cache directory doing its job, rather than a change a person would miss.
413    ///
414    /// Removal counts, on either side. A compiler that keeps an incremental
415    /// cache does not only add and rewrite: it finalizes a new session
416    /// directory and deletes the one it superseded, lock file and all. A rule
417    /// that forgave creation and rewriting but not removal therefore passed the
418    /// first build in a fresh worktree and failed the second one, which is the
419    /// build a reviewer runs to check a finding. Two of these rules disagreeing
420    /// about the same directory is what cost the run: the read-only check
421    /// forgave the deleted session and the commit-side check called it
422    /// unrepresentable, so the work was already on a pull request when the run
423    /// ended. There is one rule now, and this is it.
424    fn generated_move(&self, after: &Self, path: &Path) -> bool {
425        is_generated_artifact(path) && self.disposable_move(after, path)
426    }
427
428    /// Whether neither state has anything at `path` worth keeping, so whatever
429    /// happened there took nothing with it.
430    fn disposable_move(&self, after: &Self, path: &Path) -> bool {
431        self.disposable(path) && after.disposable(path)
432    }
433
434    /// Whether this state has nothing at `path` worth keeping: either the path
435    /// is not there at all, or it is there as an ignored file.
436    fn disposable(&self, path: &Path) -> bool {
437        !self.files.contains_key(path) || self.is_ignored(path)
438    }
439}
440
441impl GitState {
442    /// What moved between two states, said so a person can act on it.
443    ///
444    /// The guards that compare these used to report only that the worktree had
445    /// changed, which left the one question worth answering, which file, to a
446    /// `git status` run by hand against a tree that had moved on since.
447    pub(crate) fn describe_difference(&self, after: &Self) -> Option<String> {
448        let mut prefixes: BTreeSet<&PathBuf> = self.repositories.keys().collect();
449        prefixes.extend(after.repositories.keys());
450        let mut notes: Vec<String> = Vec::new();
451        let mut paths: Vec<PathBuf> = Vec::new();
452        for prefix in prefixes {
453            let label = describe_repository(prefix);
454            match (
455                self.repositories.get(prefix),
456                after.repositories.get(prefix),
457            ) {
458                (None, Some(_)) => notes.push(format!("a repository appeared at {label}")),
459                (Some(_), None) => notes.push(format!("the repository at {label} is gone")),
460                (Some(before), Some(current)) => {
461                    if before.head != current.head {
462                        notes.push(format!("HEAD moved in {label}"));
463                    }
464                    if before.unsafe_index_flags != current.unsafe_index_flags {
465                        notes.push(format!("index flags changed in {label}"));
466                    }
467                    paths.extend(
468                        differing_keys(&before.gitlinks, &current.gitlinks)
469                            .chain(differing_keys(&before.tracked, &current.tracked))
470                            .map(|path| prefix.join(path)),
471                    );
472                }
473                (None, None) => {}
474            }
475        }
476        if !paths.is_empty() {
477            paths.sort();
478            paths.dedup();
479            notes.push(format!("tracked file(s) changed: {}", list_paths(&paths)));
480        }
481        (!notes.is_empty()).then(|| notes.join("; "))
482    }
483}
484
485/// Name a repository inside a state for a report. The root carries an empty
486/// prefix, and calling that "" helps nobody.
487fn describe_repository(prefix: &Path) -> String {
488    if prefix.as_os_str().is_empty() {
489        "the worktree".to_string()
490    } else {
491        format!("the submodule at {:?}", prefix.as_os_str())
492    }
493}
494
495/// The keys whose values differ between two maps, present on either side.
496fn differing_keys<'a, V: PartialEq>(
497    before: &'a BTreeMap<PathBuf, V>,
498    after: &'a BTreeMap<PathBuf, V>,
499) -> impl Iterator<Item = &'a PathBuf> {
500    let mut keys: BTreeSet<&'a PathBuf> = before.keys().collect();
501    keys.extend(after.keys());
502    keys.into_iter()
503        .filter(move |key| before.get(*key) != after.get(*key))
504}
505
506/// What a read-only call left in the untracked and ignored tree.
507///
508/// Split rather than reduced to a yes or no, because the two halves are
509/// answered differently: one ends the call, the other is said out loud and the
510/// answer is kept.
511#[derive(Default)]
512pub(crate) struct ReadOnlyLeavings {
513    /// Disposable on both sides, so out of scope for a call that only read.
514    /// Recognized build output is left out: rebuilding is what a reviewer was
515    /// asked to do, and nothing reports it today.
516    pub(crate) tolerated: Vec<PathBuf>,
517    /// Everything else. The answer does not survive these.
518    pub(crate) condemning: Vec<PathBuf>,
519}
520
521/// Put two accounts of what moved into one sentence, keeping whichever of them
522/// there is. Both guards that report a changed worktree ask the tracked tree
523/// and the untracked tree separately, and either can be the only answer.
524pub(crate) fn join_reasons(first: Option<String>, second: Option<String>) -> Option<String> {
525    match (first, second) {
526        (None, None) => None,
527        (Some(one), None) | (None, Some(one)) => Some(one),
528        (Some(first), Some(second)) => Some(format!("{first}; {second}")),
529    }
530}
531
532/// Name some paths for a person, without printing a build directory at them.
533///
534/// Every report of a path that stopped or was let through a call goes through
535/// this, so an error and the warning beside it read alike.
536pub(crate) fn list_paths(paths: &[PathBuf]) -> String {
537    const SHOWN: usize = 5;
538    let mut listed = paths
539        .iter()
540        .take(SHOWN)
541        .map(|path| format!("{:?}", path.as_os_str()))
542        .collect::<Vec<_>>()
543        .join(", ");
544    if paths.len() > SHOWN {
545        listed.push_str(&format!(", and {} more", paths.len() - SHOWN));
546    }
547    listed
548}
549
550/// Build output one commit attempt let through, gathered for a single report.
551///
552/// The checks that allow it run more than once per attempt, before staging,
553/// after staging, and again after the commit, because the tree could have moved
554/// under any of them. Reporting from inside each check said the same thing
555/// about the same files two and three times over.
556#[derive(Default)]
557struct GeneratedArtifacts {
558    new_paths: BTreeSet<PathBuf>,
559    changed_paths: BTreeSet<PathBuf>,
560    removed_paths: BTreeSet<PathBuf>,
561}
562
563impl GeneratedArtifacts {
564    fn left(&mut self, allowed: AllowedArtifacts) {
565        self.new_paths.extend(allowed.present);
566        self.removed_paths.extend(allowed.removed);
567    }
568
569    fn changed(&mut self, allowed: AllowedArtifacts) {
570        self.changed_paths.extend(allowed.present);
571        self.removed_paths.extend(allowed.removed);
572    }
573
574    /// Said once, and not as a warning. The files stay out of the commit,
575    /// whatever wrote them writes them again, and they no longer keep the
576    /// worktree from being removed.
577    fn report(&self, cwd: &Path) {
578        if !self.new_paths.is_empty() {
579            logdim!(
580                "the editing call left {} generated artifact(s) under a known build or cache \
581                 directory in {}. They are not part of the commit.",
582                self.new_paths.len(),
583                cwd.display()
584            );
585        }
586        if !self.changed_paths.is_empty() {
587            logdim!(
588                "the editing call changed {} existing generated artifact(s) under a known build \
589                 or cache directory in {}. They are not part of the commit.",
590                self.changed_paths.len(),
591                cwd.display()
592            );
593        }
594        if !self.removed_paths.is_empty() {
595            logdim!(
596                "the editing call removed {} generated artifact(s) under a known build or cache \
597                 directory in {}. Whatever wrote them writes them again.",
598                self.removed_paths.len(),
599                cwd.display()
600            );
601        }
602    }
603}
604
605/// Recognized build and cache output a check let through, split by whether it
606/// is still on disk, so the report can say what happened rather than call a
607/// deleted incremental session a file that was left behind.
608#[derive(Default)]
609struct AllowedArtifacts {
610    present: Vec<PathBuf>,
611    removed: Vec<PathBuf>,
612}
613
614impl AllowedArtifacts {
615    fn split(paths: Vec<PathBuf>, after: &IgnoredState) -> Self {
616        let (present, removed) = paths
617            .into_iter()
618            .partition(|path| after.files.contains_key(path));
619        Self { present, removed }
620    }
621}
622
623/// Generated directories that test and build commands routinely create.
624///
625/// Files under these directories are never committed, and they do not keep a
626/// worktree that is otherwise finished, so running the requested tests neither
627/// stops the tracked change reaching review nor leaves a checkout behind.
628fn is_generated_artifact(path: &Path) -> bool {
629    const DIRECTORIES: &[&str] = &[
630        "target",
631        "dist",
632        "node_modules",
633        "__pycache__",
634        ".pytest_cache",
635        ".mypy_cache",
636        ".ruff_cache",
637        ".tox",
638        ".nox",
639        ".venv",
640        "venv",
641        ".gradle",
642        ".build",
643        "DerivedData",
644        ".next",
645        ".nuxt",
646        ".svelte-kit",
647        ".turbo",
648        "coverage",
649    ];
650    path.components().any(|component| {
651        let std::path::Component::Normal(name) = component else {
652            return false;
653        };
654        DIRECTORIES
655            .iter()
656            .any(|directory| name == OsStr::new(directory))
657    })
658}
659
660fn merge_pr_args<'a>(
661    number: &'a str,
662    expected_head: Option<&'a str>,
663    delete_branch: bool,
664) -> Vec<&'a str> {
665    let mut args = vec!["pr", "merge", number, "--squash"];
666    if delete_branch {
667        args.push("--delete-branch");
668    }
669    if let Some(expected_head) = expected_head {
670        args.extend(["--match-head-commit", expected_head]);
671    }
672    args
673}
674
675fn reconcile_pr_creation(
676    branch: &str,
677    created: Result<String>,
678    found: Result<Option<PrRef>>,
679) -> Result<PrRef> {
680    match (created, found) {
681        (_, Ok(Some(pr))) => Ok(pr),
682        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
683            "PR creation reported success but none was found for {branch}"
684        ))),
685        (Err(create), Ok(None)) => Err(spar_err!(
686            "could not open a PR for {branch}. {}",
687            create.last_line()
688        )),
689        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
690            "PR creation reported success for {branch}, but it could not be verified. {}",
691            check.last_line()
692        ))),
693        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
694            "could not open a PR for {branch}. {} The result could not be verified: {}",
695            create.last_line(),
696            check.last_line()
697        ))),
698    }
699}
700
701fn pr_for_base(text: &str, branch: &str, base: &str) -> Result<Option<PrRef>> {
702    #[derive(Deserialize)]
703    #[serde(rename_all = "camelCase")]
704    struct Row {
705        number: i64,
706        #[serde(default)]
707        url: String,
708        #[serde(default)]
709        title: String,
710        base_ref_name: String,
711    }
712
713    let rows = serde_json::from_str::<Vec<Row>>(text.trim()).map_err(|e| {
714        spar_err!("unexpected pull request list for branch {branch} against {base}: {e}")
715    })?;
716    Ok(rows
717        .into_iter()
718        .find(|row| row.base_ref_name == base)
719        .map(|row| PrRef {
720            number: row.number,
721            url: row.url,
722            title: row.title,
723        }))
724}
725
726/// Sign a comment spar composed, so it can be told from one the same account
727/// wrote by hand. Idempotent: a body already signed is returned as it is.
728fn signed(body: &str) -> String {
729    if body.contains(COMMENT_MARKER) {
730        return body.to_string();
731    }
732    format!("{body}\n\n{COMMENT_MARKER}")
733}
734
735/// Whether a comment body is one spar composed rather than one a person wrote.
736pub fn is_spar_comment(body: &str) -> bool {
737    body.contains(COMMENT_MARKER) || body.contains(STATE_MARKER)
738}
739
740fn has_exact_comment(comments: &[Value], body: &str) -> bool {
741    comments.iter().any(|comment| {
742        comment
743            .get("body")
744            .and_then(Value::as_str)
745            .is_some_and(|seen| seen == body)
746    })
747}
748
749fn reconcile_comment_post(
750    number: i64,
751    body: &str,
752    post_error: crate::error::SparError,
753    comments: Result<Vec<Value>>,
754) -> Result<()> {
755    match comments {
756        Ok(comments) if has_exact_comment(&comments, body) => Ok(()),
757        Ok(_) => Err(post_error),
758        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
759            "could not comment on #{number}. {} The result could not be verified: {}",
760            post_error.last_line(),
761            read_error.last_line()
762        ))),
763    }
764}
765
766fn reconcile_issue_edit(
767    number: i64,
768    wanted: &str,
769    edit_error: crate::error::SparError,
770    observed: Result<String>,
771) -> Result<()> {
772    match observed {
773        Ok(body) if body == wanted => Ok(()),
774        Ok(_) => Err(spar_err!(
775            "could not rewrite the body of #{number}. {}",
776            edit_error.last_line()
777        )),
778        Err(read_error) => Err(crate::error::SparError::uncertain_write(format!(
779            "could not rewrite the body of #{number}. {} The result could not be verified: {}",
780            edit_error.last_line(),
781            read_error.last_line()
782        ))),
783    }
784}
785
786fn issue_url_has_number(url: &str) -> bool {
787    url.trim()
788        .rsplit('/')
789        .next()
790        .and_then(|tail| tail.parse::<i64>().ok())
791        .is_some_and(|number| number > 0)
792}
793
794fn reconcile_issue_creation(
795    title: &str,
796    created: Result<String>,
797    found: Result<Option<ExistingIssue>>,
798) -> Result<String> {
799    match (created, found) {
800        (Ok(url), _) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
801        (_, Ok(Some(issue))) => Ok(issue.url),
802        (Ok(_), Ok(None)) => Err(crate::error::SparError::uncertain_write(format!(
803            "issue creation reported success but no matching issue was found for {title:?}"
804        ))),
805        (Err(create), Ok(None)) => Err(spar_err!(
806            "could not file issue {title:?}. {}",
807            create.last_line()
808        )),
809        (Ok(_), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
810            "issue creation reported success for {title:?}, but it could not be verified. {}",
811            check.last_line()
812        ))),
813        (Err(create), Err(check)) => Err(crate::error::SparError::uncertain_write(format!(
814            "could not file issue {title:?}. {} The result could not be verified: {}",
815            create.last_line(),
816            check.last_line()
817        ))),
818    }
819}
820
821fn remote_head_oid(output: &str, remote_ref: &str) -> Result<Option<String>> {
822    if output.trim().is_empty() {
823        return Ok(None);
824    }
825    for line in output.lines() {
826        let mut fields = line.split_whitespace();
827        let oid = fields.next().unwrap_or_default();
828        let name = fields.next().unwrap_or_default();
829        if name == remote_ref && !oid.is_empty() {
830            return Ok(Some(oid.to_string()));
831        }
832    }
833    Err(spar_err!(
834        "origin returned an unexpected ref listing for {remote_ref}"
835    ))
836}
837
838fn reconcile_failed_split_push(
839    branch: &str,
840    push_error: crate::error::SparError,
841    local: Result<String>,
842    remote: Result<String>,
843) -> std::result::Result<(), SplitPushError> {
844    let remote_ref = format!("refs/heads/{branch}");
845    match (local, remote) {
846        (Ok(local), Ok(remote)) => match remote_head_oid(&remote, &remote_ref) {
847            Ok(Some(oid)) if oid == local.trim() => Ok(()),
848            Ok(_) => Err(SplitPushError::new(
849                format!(
850                    "could not create origin/{branch}. {} The remote branch is absent or points \
851                     somewhere else. Nothing was overwritten.",
852                    push_error.last_line()
853                ),
854                false,
855            )),
856            Err(check) => Err(SplitPushError::new(
857                format!(
858                    "could not confirm whether origin/{branch} was created. {} The remote result \
859                     could not be verified: {}",
860                    push_error.last_line(),
861                    check.last_line()
862                ),
863                true,
864            )),
865        },
866        (local, remote) => {
867            let check = match (local, remote) {
868                (Err(local), Err(remote)) => format!(
869                    "the local commit could not be read: {}; origin could not be read: {}",
870                    local.last_line(),
871                    remote.last_line()
872                ),
873                (Err(local), _) => {
874                    format!("the local commit could not be read: {}", local.last_line())
875                }
876                (_, Err(remote)) => format!("origin could not be read: {}", remote.last_line()),
877                _ => unreachable!(),
878            };
879            Err(SplitPushError::new(
880                format!(
881                    "could not confirm whether origin/{branch} was created. {} The result could \
882                     not be verified because {check}",
883                    push_error.last_line()
884                ),
885                true,
886            ))
887        }
888    }
889}
890
891impl Repo {
892    pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
893        let root =
894            std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
895        // A linked worktree has a `.git` file rather than a directory, and a
896        // bare-ish layout can have neither, so ask git instead of guessing.
897        let inside = proc::run_str(
898            &["git", "rev-parse", "--is-inside-work-tree"],
899            &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
900        )
901        .unwrap_or_default();
902        if inside.trim() != "true" {
903            bail!("not a git repository: {}", root.display());
904        }
905        let repo = Self {
906            root,
907            style: cfg.style.clone(),
908            branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
909            state_store: cfg.loop_cfg.state_store,
910            followups: cfg.loop_cfg.followups,
911            drafts: cfg.loop_cfg.drafts,
912            viewer: OnceLock::new(),
913            checkpoints: Mutex::new(BTreeMap::new()),
914            writes: WriteStats::default(),
915        };
916        repo.self_exclude();
917        Ok(repo)
918    }
919
920    /// Keep spar's own scratch directories out of the target repo's
921    /// `git status`.
922    ///
923    /// Written to `.git/info/exclude`, never to a tracked `.gitignore`: this is
924    /// somebody else's repository and spar has no business committing to it.
925    /// Best effort and silent on failure, because a read-only git directory is
926    /// not a reason to abandon a run.
927    fn self_exclude(&self) {
928        let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
929        let git_dir = git_dir.trim();
930        if git_dir.is_empty() {
931            return;
932        }
933        let path = Path::new(git_dir).join("info").join("exclude");
934        let existing = std::fs::read_to_string(&path).unwrap_or_default();
935
936        // The marker too. It is written into whichever directory the call ran
937        // in, which for a shared checkout is the repository root, and an
938        // untracked file there is one `git add -A` away from being committed.
939        // The error names its path, which is how a person is meant to find it.
940        let wanted = [
941            format!("/{WORKTREE_DIR}/"),
942            format!("/{STATE_DIR}/"),
943            format!("{RECOVERY_MARKER_PREFIX}*"),
944        ];
945        let missing: Vec<&String> = wanted
946            .iter()
947            .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
948            .collect();
949        if missing.is_empty() {
950            return;
951        }
952
953        use std::io::Write;
954        if let Some(parent) = path.parent() {
955            let _ = std::fs::create_dir_all(parent);
956        }
957        let mut block = String::new();
958        if !existing.is_empty() && !existing.ends_with('\n') {
959            block.push('\n');
960        }
961        block.push_str("\n# added by spar: its worktrees, run state, and recovery markers\n");
962        for line in missing {
963            block.push_str(line);
964            block.push('\n');
965        }
966        if let Ok(mut file) = std::fs::OpenOptions::new()
967            .create(true)
968            .append(true)
969            .open(&path)
970        {
971            let _ = file.write_all(block.as_bytes());
972        }
973    }
974
975    pub fn root(&self) -> &Path {
976        &self.root
977    }
978
979    pub(crate) fn write_summary(&self) -> WriteSummary {
980        WriteSummary {
981            attempted: self.writes.attempted.load(Ordering::Relaxed),
982            failed: self.writes.failed.load(Ordering::Relaxed),
983        }
984    }
985
986    pub(crate) fn record_write<T, E>(
987        &self,
988        result: std::result::Result<T, E>,
989    ) -> std::result::Result<T, E> {
990        self.record_write_outcome(result.is_err());
991        result
992    }
993
994    pub(crate) fn record_failed_write<T, E>(
995        &self,
996        result: std::result::Result<T, E>,
997    ) -> std::result::Result<T, E> {
998        if result.is_err() {
999            self.record_write_outcome(true);
1000        }
1001        result
1002    }
1003
1004    fn record_write_outcome(&self, failed: bool) {
1005        self.writes.attempted.fetch_add(1, Ordering::Relaxed);
1006        if failed {
1007            self.writes.failed.fetch_add(1, Ordering::Relaxed);
1008        }
1009    }
1010
1011    // -- gates ------------------------------------------------------------
1012
1013    /// Scrub, then verify. A leak here reaches GitHub, so it is a hard error
1014    /// rather than a warning: silent partial compliance is how a style rule
1015    /// erodes over a long run.
1016    pub fn clean(&self, text: &str) -> Result<String> {
1017        let out = style::scrub(text, &self.style);
1018        let bad = style::violations(&out, &self.style);
1019        if !bad.is_empty() {
1020            bail!(
1021                "style gate could not clean text ({}): {}",
1022                bad.join(", "),
1023                style::clip(&out, 300)
1024            );
1025        }
1026        Ok(out)
1027    }
1028
1029    /// Clean, and hold to a length budget. For anything a model wrote.
1030    pub fn clean_body(&self, text: &str) -> Result<String> {
1031        self.clean(&style::body(text, &self.style))
1032    }
1033
1034    /// The same, with an issue's far larger budget and its exemption for code.
1035    pub fn clean_issue_body(&self, text: &str) -> Result<String> {
1036        self.clean(&style::issue_body(text, &self.style))
1037    }
1038
1039    /// The single transform every outbound title goes through.
1040    ///
1041    /// Scrub first, clip second, and never the other way round. Clipping first
1042    /// lets the scrub lengthen the result past the budget (an em dash becomes
1043    /// two characters), so a second pass would clip again and produce a
1044    /// different string. That broke follow-up deduplication silently: the
1045    /// lookup searched for one title while GitHub had stored another, no match
1046    /// was ever found, and a fresh duplicate issue was filed every review
1047    /// round. Doing it in this order makes the transform idempotent, which the
1048    /// tests assert.
1049    pub fn clean_title(&self, text: &str) -> Result<String> {
1050        Ok(style::title(&self.clean(text)?, &self.style))
1051    }
1052
1053    pub(crate) fn clean_nonempty_title_for_write(&self, text: &str) -> Result<String> {
1054        let title = self.record_failed_write(self.clean_title(text))?;
1055        if title.trim().is_empty() {
1056            return self.record_failed_write(Err(spar_err!(
1057                "nothing left of the title after cleaning it"
1058            )));
1059        }
1060        Ok(title)
1061    }
1062
1063    pub(crate) fn clean_followup_title(&self, text: &str) -> Result<String> {
1064        if self.followups == Followups::Issues {
1065            self.clean_nonempty_title_for_write(text)
1066        } else {
1067            self.clean_title(text)
1068        }
1069    }
1070
1071    // -- git --------------------------------------------------------------
1072
1073    fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
1074        ExecOpts::new()
1075            .cwd(cwd.unwrap_or(&self.root))
1076            .check(check)
1077            .timeout_secs(600)
1078    }
1079
1080    pub fn git(&self, args: &[&str]) -> Result<String> {
1081        self.git_at(None, args)
1082    }
1083
1084    pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
1085        let argv = git_without_maintenance_argv(args);
1086        proc::run(&argv, &self.git_opts(cwd, true))
1087    }
1088
1089    /// Run a parent-side Git operation without inherited background helpers.
1090    ///
1091    /// Editing calls are untrusted. Once one returns, status, staging, and
1092    /// committing happen in this process, so an inherited fsmonitor or automatic
1093    /// maintenance command must not become a way to execute outside its sandbox.
1094    fn git_at_without_automation(&self, cwd: &Path, args: &[&str]) -> Result<String> {
1095        let argv = git_without_automation_argv(args);
1096        proc::run(
1097            &argv,
1098            &self.git_opts(Some(cwd), true).stop_descendants(true),
1099        )
1100    }
1101
1102    fn git_try_without_automation(&self, args: &[&str]) -> Result<bool> {
1103        let argv = git_without_automation_argv(args);
1104        proc::exec(&argv, &self.git_opts(None, false).stop_descendants(true))
1105            .map(|output| output.ok())
1106    }
1107
1108    /// Run git, tolerating failure. Returns whatever landed on stdout.
1109    pub fn git_try(&self, args: &[&str]) -> String {
1110        self.git_try_at(None, args)
1111    }
1112
1113    pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
1114        let argv = git_without_maintenance_argv(args);
1115        proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
1116    }
1117
1118    /// The base branch the remote actually points at, rather than assuming
1119    /// `main`. Falls back to the configured value when there is no origin.
1120    pub fn default_branch(&self, configured: &str) -> String {
1121        let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
1122        match refname.trim().rsplit('/').next() {
1123            Some(name) if !name.is_empty() => name.to_string(),
1124            _ => configured.to_string(),
1125        }
1126    }
1127
1128    // -- branch naming and ownership --------------------------------------
1129    //
1130    // Branch names default to `issue-N`, which is exactly what a person would
1131    // name a branch by hand. Ownership therefore cannot be inferred from the
1132    // name, so every branch spar creates is recorded and cleanup only ever
1133    // touches what is in that record.
1134
1135    pub fn branch_for_issue(&self, issue: i64) -> String {
1136        format!("{}issue-{issue}", self.branch_prefix)
1137    }
1138
1139    pub fn branch_for_pr(&self, number: i64) -> String {
1140        format!("{}pr-{number}", self.branch_prefix)
1141    }
1142
1143    /// One part of a split, numbered from 1 within its parent.
1144    ///
1145    /// Its own namespace rather than `issue-N`, because the parts of a split
1146    /// pull request have no issue of their own and would otherwise collide with
1147    /// the branch of the issue that happens to share the parent's number.
1148    ///
1149    /// The name a part is tried on first. `worktree_for_split` may end up on a
1150    /// suffixed one, because this name is not free forever.
1151    pub fn branch_for_split(&self, parent: i64, index: usize) -> String {
1152        format!("{}{}", self.branch_prefix, split_slot(parent, index, 1))
1153    }
1154
1155    fn ledger_path(&self) -> PathBuf {
1156        self.root.join(STATE_DIR).join("branches.json")
1157    }
1158
1159    pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
1160        std::fs::read_to_string(self.ledger_path())
1161            .ok()
1162            .and_then(|text| serde_json::from_str(&text).ok())
1163            .unwrap_or_default()
1164    }
1165
1166    pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
1167        let mut data = self.known_branches();
1168        data.insert(
1169            branch.to_string(),
1170            BranchRecord {
1171                kind: kind.to_string(),
1172                number,
1173            },
1174        );
1175        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1176            logdim!("could not record branch {branch}: {e}");
1177        }
1178    }
1179
1180    pub fn forget_branch(&self, branch: &str) {
1181        let mut data = self.known_branches();
1182        if data.remove(branch).is_none() {
1183            return;
1184        }
1185        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
1186            logdim!("could not update the branch record: {e}");
1187        }
1188    }
1189
1190    // -- worktrees --------------------------------------------------------
1191
1192    fn worktree_path(&self, name: &str) -> PathBuf {
1193        self.root.join(WORKTREE_DIR).join(name)
1194    }
1195
1196    /// Isolate an issue so a failed run cannot poison the next one's base.
1197    pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
1198        let branch = self.branch_for_issue(issue);
1199        let path = self.worktree_path(&format!("issue-{issue}"));
1200
1201        self.refuse_issue_branch_rebuild(issue, base)?;
1202        self.refuse_dirty_worktree(
1203            &path,
1204            &format!("worktree for issue #{issue}"),
1205            Disposable::BuildOutput,
1206        )?;
1207
1208        if !self.branch_deletion_is_safe(&branch)? {
1209            bail!(
1210                "the existing branch {branch} has a tip or reflog-only commit that no surviving \
1211                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1212                 before retrying."
1213            );
1214        }
1215
1216        if !self.remove_worktree_at(&path)? {
1217            bail!(
1218                "the existing worktree for issue #{issue} could not be removed safely. Its \
1219                 branch was kept."
1220            );
1221        }
1222        if !self.delete_branch_if_safe(&branch)? {
1223            bail!(
1224                "the existing branch {branch} changed or remained checked out while its \
1225                 worktree was being rebuilt. It was kept."
1226            );
1227        }
1228
1229        if let Some(parent) = path.parent() {
1230            std::fs::create_dir_all(parent)
1231                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1232        }
1233
1234        let path_str = path.display().to_string();
1235        let remote_start = format!("origin/{base}");
1236        let created = self
1237            .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
1238            .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
1239
1240        // Recorded on both paths: an unrecorded branch is one cleanup will
1241        // never remove, and the fallback creates a branch just the same.
1242        created.map_err(|e| {
1243            spar_err!(
1244                "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
1245                 and does `origin` exist?",
1246                e.last_line()
1247            )
1248        })?;
1249        self.record_branch(&branch, "issue", issue);
1250        Ok((path, branch))
1251    }
1252
1253    /// Refuse to reset the local or remote branch assigned to an issue when it
1254    /// carries work that no pull request preserves.
1255    ///
1256    /// Both linked-worktree mode and shared-checkout mode rebuild the same
1257    /// branch name. Keeping the guard here prevents either path from silently
1258    /// replacing recovery commits left by an earlier run.
1259    pub(crate) fn refuse_issue_branch_rebuild(&self, issue: i64, base: &str) -> Result<()> {
1260        let branch = self.branch_for_issue(issue);
1261        let base_remote_ref = format!("refs/heads/{base}");
1262        let base_tracking_ref = format!("refs/remotes/origin/{base}");
1263        let base_refspec = format!("+{base_remote_ref}:{base_tracking_ref}");
1264        self.git(&["fetch", "--no-tags", "origin", &base_refspec])
1265            .map_err(|e| {
1266                spar_err!(
1267                    "could not refresh origin/{base} before checking issue #{issue}: {}",
1268                    e.last_line()
1269                )
1270            })?;
1271
1272        if let Some(remote_ref) = self.refresh_issue_remote_ref(&branch)? {
1273            let ahead = self.commit_count_checked(&self.root, &remote_ref, base)?;
1274            if ahead > 0 && !self.pull_request_holds(&branch, &remote_ref, base) {
1275                bail!(
1276                    "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
1277                     pull request accounts for them. Rebuilding it would force push over that \
1278                     work.\nOpen a pull request for the branch and run `spar resume <pr>` to continue \
1279                     it, or delete it with `git push origin --delete {branch}` if the remote \
1280                     branch is no longer needed."
1281                );
1282            }
1283        }
1284
1285        let local_ref = format!("refs/heads/{branch}");
1286        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1287            let ahead = self.commit_count_checked(&self.root, &local_ref, base)?;
1288            let recorded_pr = self
1289                .known_branches()
1290                .get(&branch)
1291                .is_some_and(|record| record.kind == "pr");
1292            let preserved = ahead == 0
1293                || if recorded_pr {
1294                    self.local_branch_is_preserved(&branch)?
1295                } else {
1296                    self.pull_request_holds(&branch, &local_ref, base)
1297                };
1298            if !preserved {
1299                let listed = self
1300                    .commit_lines(&self.root, &local_ref, base)
1301                    .iter()
1302                    .map(|line| format!("  {line}"))
1303                    .collect::<Vec<_>>()
1304                    .join("\n");
1305                bail!(
1306                    "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
1307                     no pull request preserves them. Rebuilding it would delete the only copy.\n\
1308                     {listed}\nPush it and run `spar resume <pr>` on the pull request to continue \
1309                     it, or delete it with `git branch -D {branch}` if it is stale."
1310                );
1311            }
1312        }
1313        Ok(())
1314    }
1315
1316    fn refresh_issue_remote_ref(&self, branch: &str) -> Result<Option<String>> {
1317        let live_ref = format!("refs/heads/{branch}");
1318        let tracking_ref = format!("refs/remotes/origin/{branch}");
1319        let listed = self
1320            .git(&["ls-remote", "--heads", "origin", &live_ref])
1321            .map_err(|e| {
1322                spar_err!(
1323                    "could not verify whether origin/{branch} still exists: {}",
1324                    e.last_line()
1325                )
1326            })?;
1327
1328        if remote_head_oid(&listed, &live_ref)?.is_some() {
1329            let refspec = format!("+{live_ref}:{tracking_ref}");
1330            self.git(&["fetch", "--no-tags", "origin", &refspec])
1331                .map_err(|e| {
1332                    spar_err!(
1333                        "origin/{branch} exists but its tracking ref could not be refreshed: {}",
1334                        e.last_line()
1335                    )
1336                })?;
1337            if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1338                bail!("origin/{branch} was fetched but its tracking ref is missing");
1339            }
1340            return Ok(Some(tracking_ref));
1341        }
1342
1343        if !self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1344            return Ok(None);
1345        }
1346        let expected = self
1347            .git_at(Some(&self.root), &["rev-parse", "--verify", &tracking_ref])?
1348            .trim()
1349            .to_string();
1350        self.git_at_without_automation(&self.root, &["update-ref", "-d", &tracking_ref, &expected])
1351            .map_err(|e| {
1352                spar_err!(
1353                    "could not discard stale origin/{branch} tracking ref safely: {}",
1354                    e.last_line()
1355                )
1356            })?;
1357        if self.exact_ref_exists_checked(&self.root, &tracking_ref)? {
1358            bail!(
1359                "origin/{branch} changed while its stale tracking ref was being removed. It was \
1360                 kept."
1361            );
1362        }
1363        Ok(None)
1364    }
1365
1366    /// Whether a pull request from `branch` already holds every commit `refname`
1367    /// has beyond `base`.
1368    ///
1369    /// GitHub serves `refs/pull/N/head` for as long as the repository lives, so
1370    /// commits that reached a pull request outlive the branch they were pushed
1371    /// from. A matching branch name does not establish that on its own: an
1372    /// issue worked twice reuses the name, and the merged pull request from the
1373    /// first round says nothing about where the second round's commits are.
1374    fn pull_request_holds(&self, branch: &str, refname: &str, base: &str) -> bool {
1375        self.prs_for_branch(branch)
1376            .iter()
1377            .any(|pr| self.pr_head_holds(pr.number, refname, base))
1378    }
1379
1380    fn pr_head_holds(&self, number: i64, refname: &str, base: &str) -> bool {
1381        let head = format!("refs/spar/pr-head/{number}");
1382        let refspec = format!("+refs/pull/{number}/head:{head}");
1383        if self.git(&["fetch", "origin", &refspec]).is_err() {
1384            return false;
1385        }
1386        let held = self.commits_held_by(refname, base, &head);
1387        self.git_try(&["update-ref", "-d", &head]);
1388        held
1389    }
1390
1391    pub(crate) fn is_ancestor_checked(&self, cwd: &Path, older: &str, newer: &str) -> Result<bool> {
1392        let argv = vec![
1393            "git".to_string(),
1394            "merge-base".to_string(),
1395            "--is-ancestor".to_string(),
1396            older.to_string(),
1397            newer.to_string(),
1398        ];
1399        let out = proc::exec(&argv, &self.git_opts(Some(cwd), false))?;
1400        match out.code {
1401            0 => Ok(true),
1402            1 => Ok(false),
1403            _ => Err(spar_err!("{}", proc::failure_message(&argv, &out))),
1404        }
1405    }
1406
1407    fn pr_head_contains_checked(&self, number: i64, branch_ref: &str) -> Result<bool> {
1408        let head = format!("refs/spar/pr-head/{number}");
1409        let refspec = format!("+refs/pull/{number}/head:{head}");
1410        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1411            spar_err!(
1412                "could not verify the immutable head of PR #{number}: {}",
1413                e.last_line()
1414            )
1415        })?;
1416        let held = self.is_ancestor_checked(&self.root, branch_ref, &head);
1417        self.git_try(&["update-ref", "-d", &head]);
1418        held
1419    }
1420
1421    fn branch_prs_checked(&self, branch: &str) -> Result<Vec<PrRef>> {
1422        let text = self.gh(&[
1423            "pr",
1424            "list",
1425            "--head",
1426            branch,
1427            "--state",
1428            "all",
1429            "--json",
1430            "number,url,title",
1431        ])?;
1432        serde_json::from_str(text.trim())
1433            .map_err(|e| spar_err!("could not read pull requests for {branch}: {e}"))
1434    }
1435
1436    fn branch_is_preserved_checked(&self, branch: &str, record: &BranchRecord) -> Result<bool> {
1437        let branch_ref = format!("refs/heads/{branch}");
1438        if record.kind == "pr" {
1439            return self.pr_head_contains_checked(record.number, &branch_ref);
1440        }
1441        let prs = self.branch_prs_checked(branch)?;
1442        if prs.is_empty() {
1443            return Ok(false);
1444        }
1445        for pr in prs {
1446            if self.pr_head_contains_checked(pr.number, &branch_ref)? {
1447                return Ok(true);
1448            }
1449        }
1450        Ok(false)
1451    }
1452
1453    fn branch_deletion_is_safe(&self, branch: &str) -> Result<bool> {
1454        let local_ref = format!("refs/heads/{branch}");
1455        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1456            return Ok(true);
1457        }
1458        let oid = self
1459            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1460            .trim()
1461            .to_string();
1462        let mut durable_tip = commit_has_shared_ref_except(&self.root, &oid, Some(&local_ref))?;
1463        if !durable_tip {
1464            let remote_ref = format!("refs/heads/{branch}");
1465            let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref])?;
1466            durable_tip = remote.lines().any(|line| {
1467                line.split_whitespace()
1468                    .next()
1469                    .is_some_and(|remote_oid| remote_oid == oid)
1470            });
1471        }
1472        if !durable_tip {
1473            if let Some(record) = self.known_branches().get(branch) {
1474                durable_tip = self.branch_is_preserved_checked(branch, record)?;
1475            }
1476        }
1477        if !durable_tip {
1478            return Ok(false);
1479        }
1480        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1481    }
1482
1483    /// Delete a branch only if its exact current tip and reflog are still safe.
1484    /// The expected old value makes a concurrent ref update fail instead of
1485    /// deleting work that appeared after the preservation check.
1486    fn delete_branch_if_safe(&self, branch: &str) -> Result<bool> {
1487        let local_ref = format!("refs/heads/{branch}");
1488        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1489            return Ok(true);
1490        }
1491        let expected = self
1492            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1493            .trim()
1494            .to_string();
1495        if !self.branch_deletion_is_safe(branch)? {
1496            return Ok(false);
1497        }
1498        let checked_out = self
1499            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])?
1500            .lines()
1501            .any(|line| line == format!("branch {local_ref}"));
1502        if checked_out {
1503            return Ok(false);
1504        }
1505        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1506        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1507    }
1508
1509    fn review_ref_deletion_is_safe(&self, number: i64) -> Result<bool> {
1510        let local_ref = review_ref(number);
1511        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1512            return Ok(true);
1513        }
1514        let oid = self
1515            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1516            .trim()
1517            .to_string();
1518        if !self.pr_head_contains_checked(number, &local_ref)? {
1519            return Ok(false);
1520        }
1521        ref_reflog_is_preserved(&self.root, &local_ref, &oid)
1522    }
1523
1524    fn delete_review_ref_if_safe(&self, number: i64) -> Result<bool> {
1525        let local_ref = review_ref(number);
1526        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1527            return Ok(true);
1528        }
1529        let expected = self
1530            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1531            .trim()
1532            .to_string();
1533        if !self.review_ref_deletion_is_safe(number)? {
1534            return Ok(false);
1535        }
1536        self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])?;
1537        Ok(!self.exact_ref_exists_checked(&self.root, &local_ref)?)
1538    }
1539
1540    /// Whether `other` already contains every commit `branch` has beyond
1541    /// `base`. False when either ref fails to resolve, so a ref that is not
1542    /// there cannot vouch for anything.
1543    pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
1544        let range = format!("{}..{branch}", self.base_ref(&self.root, base));
1545        self.git_try(&["rev-list", "--count", &range, "--not", other])
1546            .trim()
1547            == "0"
1548    }
1549
1550    pub fn worktree_remove(&self, issue: i64) -> bool {
1551        let path = self.worktree_path(&format!("issue-{issue}"));
1552        match self.remove_worktree_at(&path) {
1553            Ok(removed) => removed,
1554            Err(error) => {
1555                logdim!(
1556                    "kept {} because removal did not reach a confirmed quiet point: {}",
1557                    path.display(),
1558                    error.last_line()
1559                );
1560                false
1561            }
1562        }
1563    }
1564
1565    /// Verify both the common Git directory and the worktree top level.
1566    ///
1567    /// A stale worktree entry is not ownership proof. An unrelated repository
1568    /// can later occupy the same path and must survive cleanup.
1569    fn worktree_belongs_to_repo(&self, path: &Path) -> Result<bool> {
1570        let wanted = std::fs::canonicalize(path)
1571            .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
1572        // Every SPAR worktree path is built from the canonical repository root.
1573        // A different canonical path therefore means the final component or
1574        // one of its parents is a symlink. Passing that alias to `git worktree
1575        // remove` can delete the worktree at its real target.
1576        if wanted != path {
1577            return Ok(false);
1578        }
1579        let resolve = |cwd: &Path, value: &str| -> Result<PathBuf> {
1580            let raw = PathBuf::from(value.trim());
1581            let joined = if raw.is_absolute() {
1582                raw
1583            } else {
1584                cwd.join(raw)
1585            };
1586            std::fs::canonicalize(&joined)
1587                .map_err(|e| spar_err!("could not resolve {}: {e}", joined.display()))
1588        };
1589        let expected =
1590            self.git_at_without_automation(&self.root, &["rev-parse", "--git-common-dir"])?;
1591        let actual = self.git_at_without_automation(path, &["rev-parse", "--git-common-dir"])?;
1592        let top = self.git_at_without_automation(path, &["rev-parse", "--show-toplevel"])?;
1593        let expected = resolve(&self.root, &expected)?;
1594        let actual = resolve(path, &actual)?;
1595        let top = resolve(path, &top)?;
1596        Ok(expected == actual && top == wanted)
1597    }
1598
1599    /// Remove only a worktree that belongs to this repository.
1600    ///
1601    /// The path sits under a predictable directory, but that does not establish
1602    /// ownership. A clean independent repository at the same path must survive
1603    /// even when `git worktree remove` rejects it.
1604    fn remove_worktree_at_with_force(
1605        &self,
1606        path: &Path,
1607        force: bool,
1608        disposable: Disposable,
1609    ) -> Result<bool> {
1610        let existed = path.exists();
1611        if path.exists() {
1612            match self.worktree_belongs_to_repo(path) {
1613                Ok(true) => {}
1614                Ok(false) => {
1615                    logdim!(
1616                        "kept {} because it is not a worktree owned by this repository",
1617                        path.display()
1618                    );
1619                    return Ok(false);
1620                }
1621                Err(e) => {
1622                    logdim!(
1623                        "kept {} because its worktree ownership could not be verified: {}",
1624                        path.display(),
1625                        e.last_line()
1626                    );
1627                    return Ok(false);
1628                }
1629            }
1630            if !force {
1631                match self.has_recoverable_work(path, disposable) {
1632                    Ok(true) => {
1633                        logdim!(
1634                            "kept {} because it contains recoverable files or repository state",
1635                            path.display()
1636                        );
1637                        return Ok(false);
1638                    }
1639                    Err(e) => {
1640                        logdim!(
1641                            "kept {} because its recoverable state could not be checked: {}",
1642                            path.display(),
1643                            e.last_line()
1644                        );
1645                        return Ok(false);
1646                    }
1647                    Ok(false) => {}
1648                }
1649            }
1650        }
1651        // Git refuses to remove a worktree holding any untracked file, which is
1652        // the same question SPAR has just answered in more detail: under
1653        // `ReviewOutput` the checks above have already established that nothing
1654        // there is worth keeping. `--force` here is that answer being carried
1655        // out, not the check being skipped, which is what `force` means.
1656        let path_str = path.display().to_string();
1657        let command_ok = if force || disposable == Disposable::ReviewOutput {
1658            self.git_try_without_automation(&["worktree", "remove", "--force", &path_str])?
1659        } else {
1660            self.git_try_without_automation(&["worktree", "remove", &path_str])?
1661        };
1662        Ok((command_ok || !existed) && !path.exists())
1663    }
1664
1665    fn remove_worktree_at(&self, path: &Path) -> Result<bool> {
1666        self.remove_worktree_at_with_force(path, false, Disposable::BuildOutput)
1667    }
1668
1669    /// Remove a review checkout that is about to be rebuilt from origin.
1670    fn remove_review_worktree_at(&self, path: &Path) -> Result<bool> {
1671        self.remove_worktree_at_with_force(path, false, Disposable::ReviewOutput)
1672    }
1673
1674    /// Force removal is reserved for the explicit `clean --all` path.
1675    fn remove_worktree_at_force(&self, path: &Path) -> bool {
1676        match self.remove_worktree_at_with_force(path, true, Disposable::BuildOutput) {
1677            Ok(removed) => removed,
1678            Err(error) => {
1679                logdim!(
1680                    "kept {} because removal did not reach a confirmed quiet point: {}",
1681                    path.display(),
1682                    error.last_line()
1683                );
1684                false
1685            }
1686        }
1687    }
1688
1689    /// Remove a worktree only after a caller has verified it is unchanged.
1690    ///
1691    /// There is deliberately no force fallback, so Git can still refuse a
1692    /// removal if tracked or non-ignored work appears after the final check.
1693    fn remove_worktree_at_checked(&self, path: &Path, disposable: Disposable) -> Result<bool> {
1694        if path.exists() && !self.worktree_belongs_to_repo(path)? {
1695            bail!(
1696                "{} is not a worktree owned by this repository, so it was kept",
1697                path.display()
1698            );
1699        }
1700        if path.exists() && self.has_recoverable_work(path, disposable)? {
1701            bail!(
1702                "the verified worktree at {} contains recoverable files or repository state. It \
1703                 was kept.",
1704                path.display()
1705            );
1706        }
1707        let path_str = path.display().to_string();
1708        self.git_at_without_automation(&self.root, &["worktree", "remove", &path_str])
1709            .map_err(|e| {
1710                e.with_message(format!(
1711                    "could not remove the verified worktree at {}: {}. It was kept.",
1712                    path.display(),
1713                    e.last_line()
1714                ))
1715            })?;
1716        Ok(!path.exists())
1717    }
1718
1719    fn refuse_dirty_worktree(
1720        &self,
1721        path: &Path,
1722        label: &str,
1723        disposable: Disposable,
1724    ) -> Result<()> {
1725        if !path.is_dir() {
1726            return Ok(());
1727        }
1728        let has_files = std::fs::read_dir(path)
1729            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
1730            .next()
1731            .is_some();
1732        let owned = self.worktree_belongs_to_repo(path).map_err(|e| {
1733            spar_err!(
1734                "could not verify whether the existing {label} at {} belongs to this repository, \
1735                 so it was kept: {}",
1736                path.display(),
1737                e.last_line()
1738            )
1739        })?;
1740        if !owned {
1741            if has_files {
1742                bail!(
1743                    "the existing {label} at {} is not a worktree owned by \
1744                     this repository. Refusing to remove it.",
1745                    path.display()
1746                );
1747            }
1748            return Ok(());
1749        }
1750        if !path.join(".git").exists() {
1751            if has_files {
1752                bail!(
1753                    "the existing {label} at {} is not a readable Git worktree and is not empty. \
1754                     Refusing to remove it.",
1755                    path.display()
1756                );
1757            }
1758            return Ok(());
1759        }
1760        let dirty = self.has_recoverable_work(path, disposable).map_err(|e| {
1761            spar_err!(
1762                "could not verify whether the existing {label} at {} is clean, so it was kept: \
1763                 {}",
1764                path.display(),
1765                e.last_line()
1766            )
1767        })?;
1768        if dirty {
1769            bail!(
1770                "the existing {label} contains uncommitted changes or ignored files at {}. \
1771                 Rebuilding it would delete those files.\nCommit or recover them before running this \
1772                 command again, or use `spar clean --all` if they are not needed.",
1773                path.display()
1774            );
1775        }
1776        Ok(())
1777    }
1778
1779    /// Check an existing PR branch out into an isolated worktree.
1780    pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
1781        let head = pr.head_ref_name.clone();
1782        if head.trim().is_empty() {
1783            bail!("PR #{} has no head branch to check out", pr.number);
1784        }
1785        let path = self.worktree_path(&format!("pr-{}", pr.number));
1786        let local = self.branch_for_pr(pr.number);
1787
1788        self.git(&["fetch", "origin", &head]).map_err(|e| {
1789            spar_err!(
1790                "could not fetch the branch behind PR #{}: {}",
1791                pr.number,
1792                e.last_line()
1793            )
1794        })?;
1795        let start = format!("origin/{head}");
1796        let start_ref = format!("refs/remotes/origin/{head}");
1797        let local_ref = format!("refs/heads/{local}");
1798        if self.exact_ref_exists_checked(&self.root, &local_ref)? {
1799            let unpushed = self.commits_not_in_checked(&self.root, &local_ref, &start_ref)?;
1800            if unpushed > 0 {
1801                bail!(
1802                    "the existing worktree for PR #{} has {unpushed} local commit(s) that are not \
1803                     on {start}. Rebuilding it would delete their branch.\nInspect the worktree at \
1804                     {} and push or recover those commits before running this command again.",
1805                    pr.number,
1806                    path.display()
1807                );
1808            }
1809        }
1810        self.refuse_dirty_worktree(
1811            &path,
1812            &format!("worktree for PR #{}", pr.number),
1813            Disposable::BuildOutput,
1814        )?;
1815        if !self.branch_deletion_is_safe(&local)? {
1816            bail!(
1817                "the existing branch {local} has a tip or reflog-only commit that no surviving \
1818                 ref preserves. Rebuilding it would delete recovery history. Inspect the branch \
1819                 before retrying."
1820            );
1821        }
1822        if !self.remove_worktree_at(&path)? {
1823            bail!(
1824                "the existing worktree for PR #{} could not be removed safely. Its branch was \
1825                 kept.",
1826                pr.number
1827            );
1828        }
1829        if !self.delete_branch_if_safe(&local)? {
1830            bail!(
1831                "the existing branch {local} changed or remained checked out while the PR \
1832                 worktree was being rebuilt. It was kept."
1833            );
1834        }
1835
1836        let path_str = path.display().to_string();
1837        self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
1838        self.record_branch(&local, "pr", pr.number);
1839        Ok((path, head))
1840    }
1841
1842    /// Check a pull request's head out read only, detached, with no branch.
1843    ///
1844    /// Fetches `refs/pull/N/head`, which GitHub serves for every pull request
1845    /// including one from a fork whose branch is not in this repository at all.
1846    /// That is what makes reviewing an outside contribution possible when
1847    /// pushing to it is not.
1848    ///
1849    /// Detached on purpose. Review only mode has nothing to push, and a branch
1850    /// would only invite something to try.
1851    pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
1852        let path = self.worktree_path(&format!("review-{number}"));
1853        let local_ref = review_ref(number);
1854        let refspec = format!("+refs/pull/{number}/head:{local_ref}");
1855
1856        self.refuse_review_worktree_changes(number)?;
1857
1858        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
1859            spar_err!(
1860                "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
1861                 every pull request, so this usually means the number is wrong or `origin` does \
1862                 not point at the repository the PR is on.",
1863                e.last_line()
1864            )
1865        })?;
1866
1867        if let Some(parent) = path.parent() {
1868            std::fs::create_dir_all(parent)
1869                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1870        }
1871        if !self.remove_review_worktree_at(&path)? {
1872            bail!(
1873                "the existing review worktree for PR #{number} could not be removed safely. Its \
1874                 reference was kept."
1875            );
1876        }
1877        let path_str = path.display().to_string();
1878        self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
1879        Ok(path)
1880    }
1881
1882    fn refuse_review_worktree_changes(&self, number: i64) -> Result<()> {
1883        let path = self.worktree_path(&format!("review-{number}"));
1884        if !path.is_dir() {
1885            return Ok(());
1886        }
1887        let local_ref = review_ref(number);
1888        if !self.worktree_belongs_to_repo(&path)? {
1889            return Ok(());
1890        }
1891        if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
1892            bail!(
1893                "the existing review worktree for PR #{number} has no recorded head at \
1894                 {local_ref}. Refusing to rebuild {}.",
1895                path.display()
1896            );
1897        }
1898        let worktree_head = self.head_oid_checked(&path)?;
1899        let recorded_head = self
1900            .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
1901            .trim()
1902            .to_string();
1903        if worktree_head != recorded_head {
1904            bail!(
1905                "the existing review worktree for PR #{number} has a local commit that is not on \
1906                 {local_ref}. Rebuilding it would delete the only checkout of that work. Inspect \
1907                 {} before retrying.",
1908                path.display()
1909            );
1910        }
1911        self.report_recovery_markers(&path);
1912        self.refuse_dirty_worktree(
1913            &path,
1914            &format!("review worktree for PR #{number}"),
1915            Disposable::ReviewOutput,
1916        )?;
1917        Ok(())
1918    }
1919
1920    /// Read out what an earlier run refused to explain away, before rebuilding
1921    /// over it.
1922    ///
1923    /// The marker is the only record of why a call's answer was thrown out, and
1924    /// a review checkout is rebuilt on the next run. Saying it here is what
1925    /// keeps the file from being the thing that quietly blocks the command
1926    /// instead.
1927    fn report_recovery_markers(&self, path: &Path) {
1928        let Ok(entries) = std::fs::read_dir(path) else {
1929            return;
1930        };
1931        for entry in entries.flatten() {
1932            let name = entry.file_name();
1933            let Some(name) = name.to_str() else { continue };
1934            if !name.starts_with(RECOVERY_MARKER_PREFIX) {
1935                continue;
1936            }
1937            match std::fs::read_to_string(entry.path()) {
1938                Ok(detail) => logwarn!("an earlier run left {name}: {}", detail.trim()),
1939                Err(e) => logdim!("could not read {}: {e}", entry.path().display()),
1940            }
1941        }
1942    }
1943
1944    /// A worktree for one part of a split, on a new branch off `start`.
1945    ///
1946    /// `start` is the base branch for independent parts and the previous part's
1947    /// branch for stacked ones, which is the only difference between the two
1948    /// shapes at this level.
1949    ///
1950    /// The branch is whatever name was free, which is why it is returned rather
1951    /// than derived by the caller. Splitting the same pull request a second
1952    /// time would otherwise target the branch behind the first run's pull
1953    /// request. Split pushes are create-only and would refuse that target, but
1954    /// a repeated split still needs distinct branches rather than a name that
1955    /// can never be created.
1956    pub fn worktree_for_split(
1957        &self,
1958        parent: i64,
1959        index: usize,
1960        start: &str,
1961    ) -> Result<(PathBuf, String)> {
1962        let slot = self.free_split_slot(parent, index)?;
1963        let branch = format!("{}{slot}", self.branch_prefix);
1964        let path = self.worktree_path(&slot);
1965
1966        if let Some(dir) = path.parent() {
1967            std::fs::create_dir_all(dir)
1968                .map_err(|e| spar_err!("could not create {}: {e}", dir.display()))?;
1969        }
1970        self.refuse_dirty_worktree(
1971            &path,
1972            &format!("worktree for part {index} of PR #{parent}"),
1973            Disposable::BuildOutput,
1974        )?;
1975        // The name is free, so there is no branch to delete. A directory can
1976        // still be in the way, left by a worktree that was pruned from git's
1977        // records without being removed from disk.
1978        if !self.remove_worktree_at(&path)? {
1979            bail!(
1980                "the existing worktree for part {index} of PR #{parent} could not be removed \
1981                 safely. No branch was created."
1982            );
1983        }
1984
1985        let path_str = path.display().to_string();
1986        self.git(&["worktree", "add", "-b", &branch, &path_str, start])
1987            .map_err(|e| {
1988                spar_err!(
1989                    "could not create a worktree for part {index} of #{parent}. {}",
1990                    e.last_line()
1991                )
1992            })?;
1993        // Recorded before anything else can fail. An unrecorded branch is one
1994        // `prune_branches` will never remove.
1995        self.record_branch(&branch, "split", parent);
1996        Ok((path, branch))
1997    }
1998
1999    /// The first part branch nothing is already sitting on.
2000    ///
2001    /// Origin as well as local, because a part's branch outlives the local one:
2002    /// a second split of the same pull request finds its own earlier branches
2003    /// deleted here but alive on origin, where the pull requests that reviewed
2004    /// them still point at them.
2005    fn free_split_slot(&self, parent: i64, index: usize) -> Result<String> {
2006        for attempt in 1..=SPLIT_SLOTS {
2007            let slot = split_slot(parent, index, attempt);
2008            let branch = format!("{}{slot}", self.branch_prefix);
2009            self.git_try(&["fetch", "origin", &branch]);
2010            if !self.rev_exists(&self.root, &branch)
2011                && !self.rev_exists(&self.root, &format!("origin/{branch}"))
2012            {
2013                return Ok(slot);
2014            }
2015        }
2016        bail!(
2017            "part {index} of #{parent} has no free branch name: {} and {SPLIT_SLOTS} suffixed \
2018             names are all taken. Inspect the existing branches and child pull requests. Finish \
2019             recording the earlier split, or remove every retained local worktree and branch, \
2020             child pull request, and remote split branch before starting over.",
2021            self.branch_for_split(parent, index)
2022        )
2023    }
2024
2025    /// Whether a previous attempt pushed any branch for this split.
2026    ///
2027    /// The parent comment is the normal retry marker. A branch is the fallback
2028    /// when that comment or the pull request creation failed after the push.
2029    /// Reading origin directly makes the guard survive a fresh clone.
2030    pub fn has_remote_split_branch(&self, parent: i64) -> Result<bool> {
2031        let pattern = format!("refs/heads/{}split-{parent}-*", self.branch_prefix);
2032        Ok(!self
2033            .git(&["ls-remote", "--heads", "origin", &pattern])?
2034            .trim()
2035            .is_empty())
2036    }
2037
2038    /// Throw one part away: its worktree, its branch, and its record.
2039    ///
2040    /// For a part that would not stand on its own. Nothing has been pushed at
2041    /// that point, so this leaves no trace anywhere but the log. Takes what
2042    /// `worktree_for_split` returned, since the name it settled on is not
2043    /// derivable from the parent and the index.
2044    pub fn release_split_worktree(&self, dir: &Path, branch: &str) {
2045        match self.branch_deletion_is_safe(branch) {
2046            Ok(true) => {}
2047            Ok(false) => {
2048                logdim!(
2049                    "kept {branch} and {} because no surviving ref preserves its tip",
2050                    dir.display()
2051                );
2052                return;
2053            }
2054            Err(error) => {
2055                logdim!(
2056                    "kept {branch} and {} because preservation could not be verified: {}",
2057                    dir.display(),
2058                    error.last_line()
2059                );
2060                return;
2061            }
2062        }
2063        match self.remove_worktree_at(dir) {
2064            Ok(true) => match self.delete_branch_if_safe(branch) {
2065                Ok(true) => self.forget_branch(branch),
2066                Ok(false) => {
2067                    logdim!("kept {branch} because its tip or reflog changed before deletion")
2068                }
2069                Err(error) => logdim!(
2070                    "kept {branch} because deletion safety could not be rechecked: {}",
2071                    error.last_line()
2072                ),
2073            },
2074            Ok(false) => {}
2075            Err(error) => logdim!(
2076                "kept {branch} and {} because removal did not reach a confirmed quiet point: {}",
2077                dir.display(),
2078                error.last_line()
2079            ),
2080        }
2081    }
2082
2083    /// Discard one exact mechanical slice that the split workflow just made.
2084    ///
2085    /// Unlike ordinary release, this intentionally removes an unpushed commit.
2086    /// The caller supplies the exact disposable tip, and every file, worktree,
2087    /// ownership, and ref check must still match before anything is removed.
2088    pub fn discard_split_worktree(&self, dir: &Path, branch: &str, disposable_head: &str) -> bool {
2089        let record = self.known_branches().get(branch).cloned();
2090        if record.is_none_or(|record| record.kind != "split") {
2091            logdim!("kept {branch} because no split branch record proves ownership");
2092            return false;
2093        }
2094        let local_ref = format!("refs/heads/{branch}");
2095        let expected = match self.git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref]) {
2096            Ok(value) => value.trim().to_string(),
2097            Err(error) => {
2098                logdim!(
2099                    "kept {branch} because its tip could not be checked: {}",
2100                    error.last_line()
2101                );
2102                return false;
2103            }
2104        };
2105        if expected != disposable_head {
2106            logdim!("kept {branch} because it moved beyond the disposable slice");
2107            return false;
2108        }
2109        match ref_reflog_is_preserved(&self.root, &local_ref, disposable_head) {
2110            Ok(true) => {}
2111            Ok(false) => {
2112                logdim!(
2113                    "kept {branch} because its reflog contains work outside the disposable slice"
2114                );
2115                return false;
2116            }
2117            Err(error) => {
2118                logdim!(
2119                    "kept {branch} because its reflog could not be checked: {}",
2120                    error.last_line()
2121                );
2122                return false;
2123            }
2124        }
2125        match self.head_oid_checked(dir) {
2126            Ok(head) if head == disposable_head => {}
2127            Ok(_) => {
2128                logdim!(
2129                    "kept {branch} and {} because the worktree moved beyond the disposable slice",
2130                    dir.display()
2131                );
2132                return false;
2133            }
2134            Err(error) => {
2135                logdim!(
2136                    "kept {branch} and {} because its head could not be checked: {}",
2137                    dir.display(),
2138                    error.last_line()
2139                );
2140                return false;
2141            }
2142        }
2143        match self.remove_worktree_at_checked(dir, Disposable::BuildOutput) {
2144            Ok(true) => {}
2145            Ok(false) => return false,
2146            Err(error) => {
2147                logdim!(
2148                    "kept {branch} and {} because the disposable slice could not be verified: {}",
2149                    dir.display(),
2150                    error.last_line()
2151                );
2152                return false;
2153            }
2154        }
2155        if let Err(error) =
2156            self.git_at_without_automation(&self.root, &["update-ref", "-d", &local_ref, &expected])
2157        {
2158            logdim!(
2159                "kept {branch} because its exact disposable tip could not be deleted: {}",
2160                error.last_line()
2161            );
2162            return false;
2163        }
2164        match self.exact_ref_exists_checked(&self.root, &local_ref) {
2165            Ok(false) => {
2166                self.forget_branch(branch);
2167                true
2168            }
2169            Ok(true) => {
2170                logdim!("kept {branch} because its ref still exists after deletion");
2171                false
2172            }
2173            Err(error) => {
2174                logdim!(
2175                    "kept the branch record for {branch} because deletion could not be verified: {}",
2176                    error.last_line()
2177                );
2178                false
2179            }
2180        }
2181    }
2182
2183    pub fn release_review_worktree(&self, number: i64) {
2184        let path = self.worktree_path(&format!("review-{number}"));
2185        match self.review_ref_deletion_is_safe(number) {
2186            Ok(true) => {}
2187            Ok(false) => {
2188                logdim!(
2189                    "kept {} because no surviving ref preserves its review history",
2190                    path.display()
2191                );
2192                return;
2193            }
2194            Err(error) => {
2195                logdim!(
2196                    "kept {} because review history could not be verified: {}",
2197                    path.display(),
2198                    error.last_line()
2199                );
2200                return;
2201            }
2202        }
2203        match self.remove_worktree_at(&path) {
2204            Ok(true) => match self.delete_review_ref_if_safe(number) {
2205                Ok(true) => {}
2206                Ok(false) => logdim!(
2207                    "kept {} because its review history changed before deletion",
2208                    review_ref(number)
2209                ),
2210                Err(error) => logdim!(
2211                    "kept {} because deletion safety could not be rechecked: {}",
2212                    review_ref(number),
2213                    error.last_line()
2214                ),
2215            },
2216            Ok(false) => {}
2217            Err(error) => logdim!(
2218                "kept {} because removal did not reach a confirmed quiet point: {}",
2219                path.display(),
2220                error.last_line()
2221            ),
2222        }
2223    }
2224
2225    /// Release a read-only review checkout only when every observed part of
2226    /// its Git state still matches the checkpoint captured before the calls.
2227    pub(crate) fn release_review_worktree_checked(
2228        &self,
2229        number: i64,
2230        checkpoint: &WorktreeCheckpoint,
2231    ) -> Result<()> {
2232        let path = self.worktree_path(&format!("review-{number}"));
2233        self.require_unchanged_worktree(
2234            &path,
2235            checkpoint,
2236            &format!("review worktree for PR #{number}"),
2237        )?;
2238        if !self.review_ref_deletion_is_safe(number)? {
2239            bail!(
2240                "the review reference for PR #{number} has reflog-only recovery history. The \
2241                 worktree and reference were kept."
2242            );
2243        }
2244        if !self.remove_worktree_at_checked(&path, Disposable::BuildOutput)? {
2245            bail!(
2246                "the verified review worktree at {} could not be removed, so its reference was \
2247                 kept",
2248                path.display()
2249            );
2250        }
2251        if !self.delete_review_ref_if_safe(number)? {
2252            bail!(
2253                "the review reference for PR #{number} changed before deletion. The reference was \
2254                 kept."
2255            );
2256        }
2257        Ok(())
2258    }
2259
2260    pub fn release_pr_worktree(&self, number: i64) -> bool {
2261        let path = self.worktree_path(&format!("pr-{number}"));
2262        let local = self.branch_for_pr(number);
2263        match self.branch_deletion_is_safe(&local) {
2264            Ok(true) => {}
2265            Ok(false) => {
2266                logdim!(
2267                    "kept {local} and {} because no surviving ref preserves its tip",
2268                    path.display()
2269                );
2270                return false;
2271            }
2272            Err(error) => {
2273                logdim!(
2274                    "kept {local} and {} because preservation could not be verified: {}",
2275                    path.display(),
2276                    error.last_line()
2277                );
2278                return false;
2279            }
2280        }
2281        match self.remove_worktree_at(&path) {
2282            Ok(true) => match self.delete_branch_if_safe(&local) {
2283                Ok(true) => {
2284                    self.forget_branch(&local);
2285                    true
2286                }
2287                Ok(false) => {
2288                    logdim!("kept {local} because its tip or reflog changed before deletion");
2289                    false
2290                }
2291                Err(error) => {
2292                    logdim!(
2293                        "kept {local} because deletion safety could not be rechecked: {}",
2294                        error.last_line()
2295                    );
2296                    false
2297                }
2298            },
2299            Ok(false) => false,
2300            Err(error) => {
2301                logdim!(
2302                    "kept {local} and {} because removal did not reach a confirmed quiet point: {}",
2303                    path.display(),
2304                    error.last_line()
2305                );
2306                false
2307            }
2308        }
2309    }
2310
2311    // -- branch state -----------------------------------------------------
2312
2313    /// What to diff against: the remote tracking branch when it resolves, the
2314    /// local branch when it does not.
2315    ///
2316    /// This is not a nicety. Every "did the agent do anything" check hangs off
2317    /// this ref, and `git log` against a ref that does not exist fails silently
2318    /// and reads as "no commits". A checkout whose `origin/main` was never
2319    /// fetched would report every implementation as abandoned and throw the
2320    /// work away.
2321    pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
2322        let remote = format!("origin/{base}");
2323        if self.rev_exists(cwd, &remote) {
2324            return remote;
2325        }
2326        if self.rev_exists(cwd, base) {
2327            logdim!("origin/{base} does not resolve, comparing against local {base}");
2328            return base.to_string();
2329        }
2330        logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
2331        remote
2332    }
2333
2334    fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
2335        let spec = format!("{refname}^{{commit}}");
2336        !self
2337            .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
2338            .trim()
2339            .is_empty()
2340    }
2341
2342    pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
2343        let range = format!("{}..HEAD", self.base_ref(cwd, base));
2344        !self
2345            .git_try_at(Some(cwd), &["log", &range, "--oneline"])
2346            .trim()
2347            .is_empty()
2348    }
2349
2350    fn exact_ref_exists_checked(&self, cwd: &Path, refname: &str) -> Result<bool> {
2351        let found = self.git_at(Some(cwd), &["for-each-ref", "--format=%(refname)", refname])?;
2352        Ok(found.lines().any(|line| line.trim() == refname))
2353    }
2354
2355    fn commits_not_in_checked(&self, cwd: &Path, tip: &str, published: &str) -> Result<usize> {
2356        let count = self.git_at(Some(cwd), &["rev-list", "--count", tip, "--not", published])?;
2357        count.trim().parse::<usize>().map_err(|e| {
2358            spar_err!(
2359                "git returned an invalid commit count for {tip} outside {published}: {:?} ({e})",
2360                count.trim()
2361            )
2362        })
2363    }
2364
2365    pub(crate) fn base_ref_checked(&self, cwd: &Path, base: &str) -> Result<String> {
2366        let remote = format!("refs/remotes/origin/{base}");
2367        if self.exact_ref_exists_checked(cwd, &remote)? {
2368            return Ok(remote);
2369        }
2370        let local = format!("refs/heads/{base}");
2371        if self.exact_ref_exists_checked(cwd, &local)? {
2372            return Ok(local);
2373        }
2374        bail!("neither origin/{base} nor local branch {base} resolves")
2375    }
2376
2377    pub(crate) fn commit_count_checked(
2378        &self,
2379        cwd: &Path,
2380        refname: &str,
2381        base: &str,
2382    ) -> Result<usize> {
2383        let range = format!("{}..{refname}", self.base_ref_checked(cwd, base)?);
2384        let count = self.git_at(Some(cwd), &["rev-list", "--count", &range])?;
2385        count.trim().parse::<usize>().map_err(|e| {
2386            spar_err!(
2387                "git returned an invalid commit count for {range}: {:?} ({e})",
2388                count.trim()
2389            )
2390        })
2391    }
2392
2393    pub(crate) fn has_changes_checked(&self, cwd: &Path, base: &str) -> Result<bool> {
2394        Ok(self.commit_count_checked(cwd, "HEAD", base)? > 0)
2395    }
2396
2397    pub(crate) fn head_oid_checked(&self, cwd: &Path) -> Result<String> {
2398        let head = self.git_at(Some(cwd), &["rev-parse", "--verify", "HEAD^{commit}"])?;
2399        let head = head.trim().to_string();
2400        if head.is_empty() {
2401            bail!("git returned an empty HEAD for {}", cwd.display());
2402        }
2403        Ok(head)
2404    }
2405
2406    /// Whether a recorded SPAR branch's exact tip is retained by a pull request.
2407    ///
2408    /// Pull request head refs remain available after close or merge, so this is
2409    /// stronger than requiring an open pull request or a live remote branch.
2410    pub(crate) fn current_branch_is_preserved(&self, cwd: &Path) -> Result<bool> {
2411        let branch = self.git_at(Some(cwd), &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
2412        self.local_branch_is_preserved(branch.trim())
2413    }
2414
2415    /// Whether a recorded local branch's exact tip is retained by its pull
2416    /// request head, regardless of which branch is currently checked out.
2417    pub(crate) fn local_branch_is_preserved(&self, branch: &str) -> Result<bool> {
2418        let known = self.known_branches();
2419        let Some(record) = known.get(branch) else {
2420            return Ok(false);
2421        };
2422        self.branch_is_preserved_checked(branch, record)
2423    }
2424
2425    /// Whether tracked, staged, or non-ignored untracked files are uncommitted.
2426    pub(crate) fn has_uncommitted_changes(&self, cwd: &Path) -> Result<bool> {
2427        has_uncommitted_work(cwd)
2428    }
2429
2430    /// Whether removing a worktree would delete any local file Git does not
2431    /// reproduce from its commits, including ignored untracked files.
2432    fn has_recoverable_work(&self, cwd: &Path, disposable: Disposable) -> Result<bool> {
2433        repository_has_recoverable_work_with(cwd, true, disposable)
2434    }
2435
2436    /// Record ignored artifacts that existed before an editing call.
2437    pub(crate) fn worktree_baseline(&self, cwd: &Path) -> Result<WorktreeBaseline> {
2438        let attributes = attribute_state(cwd)?;
2439        Ok(WorktreeBaseline {
2440            attributes,
2441            ignored_untracked: ignored_untracked_state(cwd)?,
2442            git_state: safe_git_state(cwd)?,
2443        })
2444    }
2445
2446    /// Capture the Git state of a checkout intended to remain read only while
2447    /// external commands inspect it.
2448    pub(crate) fn worktree_checkpoint(&self, cwd: &Path) -> Result<WorktreeCheckpoint> {
2449        let attributes = attribute_state(cwd)?;
2450        Ok(WorktreeCheckpoint {
2451            path: std::fs::canonicalize(cwd)
2452                .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?,
2453            attributes,
2454            git_state: safe_git_state(cwd)?,
2455            ignored_untracked: ignored_untracked_state(cwd)?,
2456        })
2457    }
2458
2459    /// Require a read-only checkout to match a previously captured checkpoint.
2460    /// Any probe failure is an error because deletion cannot be proven safe.
2461    pub(crate) fn require_unchanged_worktree(
2462        &self,
2463        cwd: &Path,
2464        checkpoint: &WorktreeCheckpoint,
2465        label: &str,
2466    ) -> Result<()> {
2467        let resolved = std::fs::canonicalize(cwd).map_err(|e| {
2468            crate::error::SparError::uncertain_write(format!(
2469                "could not resolve the {label} at {} after inspection: {e}. It was kept.",
2470                cwd.display()
2471            ))
2472        })?;
2473        if resolved != checkpoint.path {
2474            return Err(uncertain_worktree_change(
2475                cwd,
2476                format!(
2477                    "the {label} moved from {} to {} during inspection. It was kept.",
2478                    checkpoint.path.display(),
2479                    resolved.display()
2480                ),
2481            ));
2482        }
2483        let attributes = probe_again_on_failure(|| attribute_state(cwd)).map_err(|e| {
2484            uncertain_worktree_change(
2485                cwd,
2486                format!(
2487                    "could not verify attribute files in the {label} at {}: {}. It was kept.",
2488                    cwd.display(),
2489                    e.last_line()
2490                ),
2491            )
2492        })?;
2493        if attributes != checkpoint.attributes {
2494            return Err(uncertain_worktree_change(
2495                cwd,
2496                format!(
2497                    "attribute files in the {label} at {} changed during inspection. It was \
2498                     kept for recovery.",
2499                    cwd.display()
2500                ),
2501            ));
2502        }
2503        let git_state = probe_again_on_failure(|| git_state(cwd)).map_err(|e| {
2504            uncertain_worktree_change(
2505                cwd,
2506                format!(
2507                    "could not verify the Git state of the {label} at {}: {}. It was kept.",
2508                    cwd.display(),
2509                    e.last_line()
2510                ),
2511            )
2512        })?;
2513        let ignored = probe_again_on_failure(|| ignored_untracked_state(cwd)).map_err(|e| {
2514            uncertain_worktree_change(
2515                cwd,
2516                format!(
2517                    "could not verify untracked files in the {label} at {}: {}. It was kept.",
2518                    cwd.display(),
2519                    e.last_line()
2520                ),
2521            )
2522        })?;
2523        let leavings = checkpoint.ignored_untracked.read_only_leavings(&ignored);
2524        // Said even when the inspection is accepted, for the same reason it is
2525        // said on an agent call: a path the project ignores is out of scope,
2526        // not unnoticed.
2527        if !leavings.tolerated.is_empty() {
2528            logwarn!(
2529                "a read-only inspection left {} ignored file(s) changed in the {label} at {}: {}. \
2530                 The inspection was accepted; the project ignores these paths.",
2531                leavings.tolerated.len(),
2532                cwd.display(),
2533                list_paths(&leavings.tolerated)
2534            );
2535        }
2536        let tracked = checkpoint.git_state.describe_difference(&git_state);
2537        let untracked = (!leavings.condemning.is_empty()).then(|| {
2538            format!(
2539                "untracked file(s) changed: {}",
2540                list_paths(&leavings.condemning)
2541            )
2542        });
2543        let Some(reason) = join_reasons(tracked, untracked) else {
2544            return Ok(());
2545        };
2546        Err(uncertain_worktree_change(
2547            cwd,
2548            format!(
2549                "the {label} at {} changed during a read-only inspection: {reason}. It was kept \
2550                 for recovery.",
2551                cwd.display()
2552            ),
2553        ))
2554    }
2555
2556    /// Refuse to discard ignored files that appeared during a call.
2557    ///
2558    /// Call this when the call reported success but produced no commit-worthy
2559    /// status. Existing ignored files are harmless because they are present in
2560    /// `baseline`; only new paths stop cleanup, and recognized build and cache
2561    /// output is not one of them. Running the project's tests is what a call is
2562    /// asked to do, and whatever wrote that output writes it again.
2563    pub(crate) fn refuse_new_ignored_files(
2564        &self,
2565        cwd: &Path,
2566        baseline: &WorktreeBaseline,
2567    ) -> Result<()> {
2568        self.check_new_ignored_files(cwd, baseline).map(drop)
2569    }
2570
2571    /// Hold a call that was asked only to read to the read-only rule, rather
2572    /// than to the one a managed commit is held to.
2573    ///
2574    /// A review is judged twice: once by the guard inside the call, and again
2575    /// here, before the round continues. Asking two different questions about
2576    /// the same paths is what cost a run. A reviewer rebuilt a project whose
2577    /// build output lands in `manager/public/assets/`, the guard inside the
2578    /// call trusted the project's ignore file and kept a review that had taken
2579    /// five minutes, and this check then called the very same files
2580    /// unrepresentable and ended the round anyway. A managed commit is not in
2581    /// question after a call that committed nothing, so the commit-side rule
2582    /// has no business being applied to one. One rule, asked once.
2583    pub(crate) fn refuse_read_only_leavings(
2584        &self,
2585        cwd: &Path,
2586        baseline: &WorktreeBaseline,
2587    ) -> Result<()> {
2588        self.refuse_changed_attributes(cwd, baseline)?;
2589        let after = ignored_untracked_state(cwd).map_err(|e| {
2590            uncertain_worktree_change(
2591                cwd,
2592                format!(
2593                    "could not verify untracked files in {} after a read-only call: {}. The \
2594                     worktree was kept for recovery.",
2595                    cwd.display(),
2596                    e.last_line()
2597                ),
2598            )
2599        })?;
2600        let leavings = baseline.ignored_untracked.read_only_leavings(&after);
2601        if leavings.condemning.is_empty() {
2602            return Ok(());
2603        }
2604        Err(uncertain_worktree_change(
2605            cwd,
2606            format!(
2607                "the read-only call left untracked file(s) in {} that the project does not \
2608                 ignore: {}. The worktree was kept for recovery.",
2609                cwd.display(),
2610                list_paths(&leavings.condemning)
2611            ),
2612        ))
2613    }
2614
2615    /// The generated paths the check let through, for one report per attempt
2616    /// rather than one per check.
2617    fn allow_generated_ignored_files(
2618        &self,
2619        cwd: &Path,
2620        baseline: &WorktreeBaseline,
2621    ) -> Result<AllowedArtifacts> {
2622        self.check_new_ignored_files(cwd, baseline)
2623    }
2624
2625    fn check_new_ignored_files(
2626        &self,
2627        cwd: &Path,
2628        baseline: &WorktreeBaseline,
2629    ) -> Result<AllowedArtifacts> {
2630        self.refuse_changed_attributes(cwd, baseline)?;
2631        let after = ignored_untracked_state(cwd).map_err(|e| {
2632            uncertain_worktree_change(
2633                cwd,
2634                format!(
2635                    "could not verify untracked files in {} after editing: {}. The worktree was \
2636                     kept for recovery.",
2637                    cwd.display(),
2638                    e.last_line()
2639                ),
2640            )
2641        })?;
2642        let changed = baseline.ignored_untracked.changed_paths(&after);
2643        if changed.is_empty() {
2644            return Ok(AllowedArtifacts::default());
2645        }
2646        let (generated, changed): (Vec<_>, Vec<_>) = changed
2647            .into_iter()
2648            .partition(|path| baseline.ignored_untracked.generated_move(&after, path));
2649        if changed.is_empty() {
2650            return Ok(AllowedArtifacts::split(generated, &after));
2651        }
2652        let listed = list_paths(&changed);
2653        Err(uncertain_worktree_change(
2654            cwd,
2655            format!(
2656                "the editing call created or changed untracked or ignored file(s) in {} that \
2657                 cannot be represented by a managed commit: {listed}. The worktree was kept for \
2658                 recovery.",
2659                cwd.display()
2660            ),
2661        ))
2662    }
2663
2664    /// Existing untracked files belong to the checkout owner, even when a call
2665    /// also produces a valid tracked change. Refuse their modification or
2666    /// deletion before accepting the tracked result. Rebuilt output is not that:
2667    /// it is ignored on both sides and under a known build or cache directory,
2668    /// which is where the command the call was asked to run puts it.
2669    pub(crate) fn refuse_changed_existing_untracked(
2670        &self,
2671        cwd: &Path,
2672        baseline: &WorktreeBaseline,
2673    ) -> Result<()> {
2674        self.check_changed_existing_untracked(cwd, baseline)
2675            .map(drop)
2676    }
2677
2678    fn allow_changed_generated_artifacts(
2679        &self,
2680        cwd: &Path,
2681        baseline: &WorktreeBaseline,
2682    ) -> Result<AllowedArtifacts> {
2683        self.check_changed_existing_untracked(cwd, baseline)
2684    }
2685
2686    fn check_changed_existing_untracked(
2687        &self,
2688        cwd: &Path,
2689        baseline: &WorktreeBaseline,
2690    ) -> Result<AllowedArtifacts> {
2691        self.refuse_changed_attributes(cwd, baseline)?;
2692        let after = ignored_untracked_state(cwd).map_err(|e| {
2693            uncertain_worktree_change(
2694                cwd,
2695                format!(
2696                    "could not verify existing untracked files in {} after editing: {}. The \
2697                     worktree was kept for recovery.",
2698                    cwd.display(),
2699                    e.last_line()
2700                ),
2701            )
2702        })?;
2703        let changed = baseline.ignored_untracked.changed_existing_paths(&after);
2704        if changed.is_empty() {
2705            return Ok(AllowedArtifacts::default());
2706        }
2707        let (generated, changed): (Vec<_>, Vec<_>) = changed
2708            .into_iter()
2709            .partition(|path| baseline.ignored_untracked.generated_move(&after, path));
2710        if changed.is_empty() {
2711            return Ok(AllowedArtifacts::split(generated, &after));
2712        }
2713        let listed = list_paths(&changed);
2714        Err(uncertain_worktree_change(
2715            cwd,
2716            format!(
2717                "the editing call changed or deleted existing untracked file(s) in {}: \
2718                 {listed}. The worktree was kept for recovery.",
2719                cwd.display()
2720            ),
2721        ))
2722    }
2723
2724    /// Refuse a byte or mode change that the index did not represent.
2725    ///
2726    /// Clean filters can normalize a working file back to its existing blob,
2727    /// and index flags can hide a change from porcelain status. Comparing the
2728    /// actual tracked files on both sides keeps those bytes from being treated
2729    /// as disposable just because Git has no diff for them.
2730    pub(crate) fn refuse_unrepresented_tracked_changes(
2731        &self,
2732        cwd: &Path,
2733        baseline: &WorktreeBaseline,
2734    ) -> Result<()> {
2735        self.refuse_changed_attributes(cwd, baseline)?;
2736        let after = safe_git_state(cwd).map_err(|e| {
2737            uncertain_worktree_change(
2738                cwd,
2739                format!(
2740                    "could not verify tracked files in {} after editing: {}. The worktree was \
2741                     kept for recovery.",
2742                    cwd.display(),
2743                    e.last_line()
2744                ),
2745            )
2746        })?;
2747        let mut changed = Vec::new();
2748        let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
2749            uncertain_worktree_change(
2750                cwd,
2751                format!(
2752                    "could not record untracked files before verifying transformed content in {}: \
2753                     {}. The worktree was kept for recovery.",
2754                    cwd.display(),
2755                    e.last_line()
2756                ),
2757            )
2758        })?;
2759        let mut filter_was_run = false;
2760        let mut filter_problem = None;
2761        let mut repositories: BTreeSet<PathBuf> =
2762            baseline.git_state.repositories.keys().cloned().collect();
2763        repositories.extend(after.repositories.keys().cloned());
2764        'repositories: for repository_path in repositories {
2765            let before_repository = baseline.git_state.repositories.get(&repository_path);
2766            let after_repository = after.repositories.get(&repository_path);
2767            if before_repository.is_none() || after_repository.is_none() {
2768                changed.push(repository_path.clone());
2769                continue;
2770            }
2771            if before_repository.map(|repository| &repository.gitlinks)
2772                != after_repository.map(|repository| &repository.gitlinks)
2773            {
2774                changed.push(repository_path.join("<gitlinks>"));
2775            }
2776            let mut paths = BTreeSet::new();
2777            if let Some(repository) = before_repository {
2778                paths.extend(repository.tracked.keys().cloned());
2779            }
2780            if let Some(repository) = after_repository {
2781                paths.extend(repository.tracked.keys().cloned());
2782            }
2783            for path in paths {
2784                let before = before_repository.and_then(|repository| repository.tracked.get(&path));
2785                let current = after_repository.and_then(|repository| repository.tracked.get(&path));
2786                let worktree_changed =
2787                    before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
2788                let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
2789                    != current.map(|entry| (&entry.index_mode, &entry.index_oid));
2790                if !worktree_changed {
2791                    continue;
2792                }
2793                if !index_changed {
2794                    changed.push(repository_path.join(&path));
2795                    continue;
2796                }
2797                let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
2798                let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
2799                let Some(current_entry) = current else {
2800                    continue;
2801                };
2802                let Some(current_worktree) = current_worktree else {
2803                    continue;
2804                };
2805                let content_changed =
2806                    before_worktree.map(|file| file.content) != Some(current_worktree.content);
2807                let mode_changed = before_worktree.map(|file| file.mode.as_str())
2808                    != Some(current_worktree.mode.as_str());
2809                let repository = cwd.join(&repository_path);
2810                let represented_content = if content_changed {
2811                    filter_was_run = true;
2812                    let result =
2813                        filtered_index_content(&repository, &path, &current_entry.index_oid);
2814                    self.refuse_changed_attributes(cwd, baseline)?;
2815                    match result {
2816                        Ok(expected) => expected == current_worktree.content,
2817                        Err(error) => {
2818                            filter_problem = Some(format!(
2819                                "could not verify transformed content for {:?}: {}",
2820                                repository_path.join(&path),
2821                                error.last_line()
2822                            ));
2823                            false
2824                        }
2825                    }
2826                } else {
2827                    true
2828                };
2829                let represented_mode =
2830                    !mode_changed || current_worktree.mode == current_entry.index_mode;
2831                if !represented_content || !represented_mode {
2832                    changed.push(repository_path.join(&path));
2833                }
2834                if filter_problem.is_some() {
2835                    break 'repositories;
2836                }
2837            }
2838        }
2839        if filter_was_run {
2840            self.refuse_changed_attributes(cwd, baseline)?;
2841            let verified = safe_git_state(cwd).map_err(|e| {
2842                uncertain_worktree_change(
2843                    cwd,
2844                    format!(
2845                        "could not recheck tracked files after verifying transformed content in \
2846                         {}: {}. The worktree was kept for recovery.",
2847                        cwd.display(),
2848                        e.last_line()
2849                    ),
2850                )
2851            })?;
2852            let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
2853                uncertain_worktree_change(
2854                    cwd,
2855                    format!(
2856                        "could not recheck untracked files after verifying transformed content \
2857                         in {}: {}. The worktree was kept for recovery.",
2858                        cwd.display(),
2859                        e.last_line()
2860                    ),
2861                )
2862            })?;
2863            if verified != after
2864                || before_filter_untracked.changed_beyond_generated(&verified_untracked)
2865            {
2866                return Err(uncertain_worktree_change(
2867                    cwd,
2868                    "a content filter changed the worktree while SPAR verified the managed \
2869                     commit. The worktree was kept for recovery.",
2870                ));
2871            }
2872            self.refuse_changed_existing_untracked(cwd, baseline)?;
2873        }
2874        if let Some(problem) = filter_problem {
2875            return Err(uncertain_worktree_change(
2876                cwd,
2877                format!("{problem}. The worktree was kept for recovery."),
2878            ));
2879        }
2880        if changed.is_empty() {
2881            return Ok(());
2882        }
2883        let listed = list_paths(&changed);
2884        Err(uncertain_worktree_change(
2885            cwd,
2886            format!(
2887                "the editing call changed tracked working-file bytes, modes, repositories, or \
2888                 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
2889                 recovery."
2890            ),
2891        ))
2892    }
2893
2894    pub(crate) fn refuse_changed_attributes(
2895        &self,
2896        cwd: &Path,
2897        baseline: &WorktreeBaseline,
2898    ) -> Result<()> {
2899        let after = attribute_state(cwd).map_err(|e| {
2900            uncertain_worktree_change(
2901                cwd,
2902                format!(
2903                    "could not verify attribute files in {} after editing: {}. The worktree was \
2904                     kept for recovery.",
2905                    cwd.display(),
2906                    e.last_line()
2907                ),
2908            )
2909        })?;
2910        if after == baseline.attributes {
2911            return Ok(());
2912        }
2913        Err(uncertain_worktree_change(
2914            cwd,
2915            format!(
2916                "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
2917                 refused to run a Git operation that could select a new external filter.",
2918                cwd.display()
2919            ),
2920        ))
2921    }
2922
2923    /// Commit a successful editing call from the trusted harness process.
2924    ///
2925    /// Editing sandboxes only need the working tree. They never need writable
2926    /// access to the repository's object database, refs, config, or hooks.
2927    pub(crate) fn commit_pending_changes(
2928        &self,
2929        cwd: &Path,
2930        baseline: &WorktreeBaseline,
2931        preferred_subject: &str,
2932        fallback_subject: &str,
2933    ) -> Result<bool> {
2934        let mut artifacts = GeneratedArtifacts::default();
2935        self.refuse_changed_attributes(cwd, baseline)?;
2936        artifacts.changed(self.allow_changed_generated_artifacts(cwd, baseline)?);
2937        refuse_unsafe_index_flags(cwd)?;
2938        if !self.has_uncommitted_changes(cwd)? {
2939            artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2940            artifacts.report(cwd);
2941            return Ok(false);
2942        }
2943        self.stage_managed_changes(cwd, baseline).map_err(|e| {
2944            e.with_message(format!(
2945                "could not stage changes in {}: {}",
2946                cwd.display(),
2947                e.last_line()
2948            ))
2949        })?;
2950        // Ignored paths remain untracked after staging. Only known generated
2951        // output may remain beside an otherwise complete managed commit.
2952        artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2953        let changed_gitlinks = changed_staged_gitlinks(cwd)?;
2954        if !changed_gitlinks.is_empty() {
2955            let listed = list_paths(&changed_gitlinks);
2956            bail!(
2957                "the editing call added or changed a gitlink at {listed}. It was staged but not \
2958                 committed because the referenced repository objects might exist only inside \
2959                 this worktree. The worktree was kept for recovery."
2960            );
2961        }
2962        let mut subject = self.clean_title(preferred_subject)?;
2963        if subject.trim().is_empty() {
2964            subject = self.clean_title(fallback_subject)?;
2965        }
2966        self.commit_staged_changes(cwd, &subject).map_err(|e| {
2967            e.with_message(format!(
2968                "could not commit changes in {}: {}. The staged files were kept.",
2969                cwd.display(),
2970                e.last_line()
2971            ))
2972        })?;
2973        if has_tracked_or_staged_work(cwd)? {
2974            bail!(
2975                "the commit in {} left additional uncommitted files. They were kept for \
2976                 recovery.",
2977                cwd.display()
2978            );
2979        }
2980        artifacts.changed(self.allow_changed_generated_artifacts(cwd, baseline)?);
2981        artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2982        artifacts.report(cwd);
2983        Ok(true)
2984    }
2985
2986    fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
2987        let after = ignored_untracked_state(cwd)?;
2988        self.git_at_without_automation(cwd, &["add", "-u"])?;
2989        let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
2990        if paths.is_empty() {
2991            return Ok(());
2992        }
2993        let mut input = Vec::new();
2994        for path in paths {
2995            input.extend(os_str_bytes(path.as_os_str())?);
2996            input.push(0);
2997        }
2998        let argv = git_without_automation_argv(&[
2999            "--literal-pathspecs",
3000            "add",
3001            "--pathspec-from-file=-",
3002            "--pathspec-file-nul",
3003        ]);
3004        proc::run_with_input_bytes(
3005            &argv,
3006            &self.git_opts(Some(cwd), true).stop_descendants(true),
3007            &input,
3008        )?;
3009        Ok(())
3010    }
3011
3012    /// Commit an index prepared by the parent without signing, hooks, or
3013    /// inherited repository automation.
3014    pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
3015        self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
3016            .map(|_| ())
3017    }
3018
3019    /// How many commits `refname` carries that the base does not.
3020    ///
3021    /// Counted from the commits themselves rather than from `commit_subjects`,
3022    /// which drops a commit whose message is empty. The guards in
3023    /// `worktree_add` decide whether to delete a branch on this number, and an
3024    /// empty message must not read as an empty branch.
3025    pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
3026        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3027        self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
3028            .trim()
3029            .parse()
3030            .unwrap_or(0)
3031    }
3032
3033    /// One `hash subject` line per commit `refname` carries that the base does
3034    /// not, oldest first. For showing a person what is on a branch, so the
3035    /// hash keeps a commit with no message from listing as nothing.
3036    pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3037        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3038        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3039            .lines()
3040            .map(str::to_string)
3041            .collect()
3042    }
3043
3044    /// The commits `later` carries that `earlier` does not, oldest first, when
3045    /// `earlier` is genuinely behind it.
3046    ///
3047    /// `None` when it is not an ancestor, which is not the same as nothing
3048    /// having landed. `rewrite_commits_if_needed` rewrites hashes from the first
3049    /// offending commit spar made onward, so a head recorded part way through a
3050    /// round can still be a readable object and no longer be on the branch. `git log` answers that
3051    /// with every commit on the branch, so without the check the one caller
3052    /// would report the whole branch as unread, which is the widest possible
3053    /// wrong answer.
3054    ///
3055    /// No `base_ref` resolution, unlike its neighbours: these are commits rather
3056    /// than branch names, and putting a sha through it logs a fallback line
3057    /// every time.
3058    pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3059        let ancestor = self
3060            .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3061            .is_ok();
3062        if !ancestor {
3063            return None;
3064        }
3065        let range = format!("{earlier}..{later}");
3066        Some(
3067            self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3068                .lines()
3069                .map(str::to_string)
3070                .collect(),
3071        )
3072    }
3073
3074    /// The subjects of the commits `refname` carries that the base does not,
3075    /// oldest first.
3076    pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3077        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3078        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3079            .lines()
3080            .map(str::trim)
3081            .filter(|line| !line.is_empty())
3082            .map(str::to_string)
3083            .collect()
3084    }
3085
3086    /// The paths this checkout changes relative to the base, sorted.
3087    ///
3088    /// A three dot range, matching `diff_stat`: what the branch did, not what
3089    /// the base has done since.
3090    ///
3091    /// `--no-renames` because a rename reported as its destination alone leaves
3092    /// the source out of the list, and a part carrying only the destination
3093    /// would be a copy. As a deletion and an addition it is two paths, which a
3094    /// part can carry together or leave to the leftover report.
3095    ///
3096    /// `-z` because without it git writes paths for display: anything
3097    /// non-ASCII comes back escaped and wrapped in quotes, and that string is
3098    /// not a path. A part built from one carries a pathspec matching no file,
3099    /// so the file never reaches the slice while every list still says the part
3100    /// took it. It also keeps a path with a space at either end intact.
3101    pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
3102        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3103        self.git_try_at(
3104            Some(cwd),
3105            &["diff", "--name-only", "--no-renames", "-z", &range],
3106        )
3107        .split('\0')
3108        .filter(|path| !path.is_empty())
3109        .map(str::to_string)
3110        .collect()
3111    }
3112
3113    /// The files one commit range touched, for checking a claim against the
3114    /// diff rather than against the report that made it.
3115    pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
3116        self.git_try_at(
3117            Some(cwd),
3118            &[
3119                "diff",
3120                "--name-only",
3121                "--no-renames",
3122                "-z",
3123                &format!("{from}..{to}"),
3124            ],
3125        )
3126        .split('\0')
3127        .filter(|path| !path.is_empty())
3128        .map(str::to_string)
3129        .collect()
3130    }
3131
3132    /// Where `refname` left the base: the commit its own change is measured
3133    /// from, and the one a slice of that change has to be taken against.
3134    pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
3135        let base_ref = self.base_ref(cwd, base);
3136        let out = self
3137            .git_at(Some(cwd), &["merge-base", &base_ref, refname])
3138            .map_err(|e| {
3139                spar_err!(
3140                    "could not find where {refname} and {base_ref} diverged. {}",
3141                    e.last_line()
3142                )
3143            })?;
3144        let sha = out.trim().to_string();
3145        if sha.is_empty() {
3146            bail!("{refname} and {base_ref} share no history");
3147        }
3148        Ok(sha)
3149    }
3150
3151    pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
3152        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3153        let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
3154        full.trim().to_string()
3155    }
3156
3157    /// Scrub commit messages that slipped past the prompt.
3158    ///
3159    /// `git filter-branch` calls back into this same binary, so there is no
3160    /// interpreter to find and no second copy of the rules to drift.
3161    ///
3162    /// `floor` is the head spar found when it started, and nothing at or below
3163    /// it is rewritten. The style gate is for what spar writes, and a person's
3164    /// commit message is not that: rewriting one changes a published SHA, which
3165    /// diverges the branch under somebody's own checkout and is exactly the
3166    /// history rewrite this design promises never to do. A `None` floor means
3167    /// every commit above the base is spar's own, which is true of a branch
3168    /// spar created in this invocation and of nothing else.
3169    pub fn rewrite_commits_if_needed(
3170        &self,
3171        cwd: &Path,
3172        base: &str,
3173        floor: Option<&str>,
3174    ) -> Result<()> {
3175        let base_ref = self.base_ref(cwd, base);
3176        let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
3177        self.report_commits_left_alone(cwd, &base_ref, floor);
3178
3179        let range = format!("{floor}..HEAD");
3180        let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
3181
3182        let offenders = raw
3183            .split('\x1e')
3184            .filter_map(|entry| entry.split_once('\0'))
3185            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3186            .count();
3187        if offenders == 0 {
3188            return Ok(());
3189        }
3190        logdim!("{offenders} commit message(s) violated style rules, rewriting");
3191
3192        let exe = self_binary()?;
3193        let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
3194
3195        let argv: Vec<String> = [
3196            "git",
3197            "filter-branch",
3198            "-f",
3199            "--msg-filter",
3200            &filter,
3201            &range,
3202        ]
3203        .iter()
3204        .map(|s| s.to_string())
3205        .collect();
3206        let opts = ExecOpts::new()
3207            .cwd(cwd)
3208            .check(false)
3209            .timeout_secs(600)
3210            .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
3211            .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
3212            .env(
3213                "SPAR_BAN_AI_ATTRIBUTION",
3214                bool_env(self.style.ban_ai_attribution),
3215            );
3216        let _ = proc::run(&argv, &opts);
3217
3218        let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
3219        if !style::violations(&after, &self.style).is_empty() {
3220            bail!(
3221                "commit messages still violate style rules after a rewrite in {}.",
3222                cwd.display()
3223            );
3224        }
3225        Ok(())
3226    }
3227
3228    /// Say out loud when a message spar will not touch breaks the style rules.
3229    ///
3230    /// Silence here would read as "there was nothing to scrub", and the point
3231    /// of the floor is that there was something and spar chose to leave it.
3232    fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
3233        if floor == base_ref {
3234            return;
3235        }
3236        let raw = self.git_try_at(
3237            Some(cwd),
3238            &[
3239                "log",
3240                &format!("{base_ref}..{floor}"),
3241                "--format=%H%x00%B%x1e",
3242            ],
3243        );
3244        let left: Vec<&str> = raw
3245            .split('\x1e')
3246            .filter_map(|entry| entry.split_once('\0'))
3247            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3248            .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
3249            .collect();
3250        if left.is_empty() {
3251            return;
3252        }
3253        logdim!(
3254            "{} commit message(s) already on the branch break the style rules and were left \
3255             alone: {}. spar does not rewrite commits it did not write.",
3256            left.len(),
3257            left.join(", ")
3258        );
3259    }
3260
3261    /// Push by explicit refspec from HEAD, leasing the head spar reviewed.
3262    ///
3263    /// A resumed PR is checked out under a local name (`pr-N`) that does not
3264    /// match its remote branch, so pushing by branch name would resolve the
3265    /// wrong local ref or fail outright.
3266    ///
3267    /// `expected` is the remote head spar last read. A bare `--force-with-lease`
3268    /// leases the implicit tracking ref instead, which is shared across every
3269    /// worktree and is updated by any `git fetch`. The editing agent is a real
3270    /// CLI with shell access, and fetching is a normal thing for it to do: one
3271    /// `git fetch` mid round moves the tracking ref to whatever the remote holds
3272    /// and the lease then passes against a head spar never reviewed, which
3273    /// silently drops somebody's commit. Naming the head makes the push refuse
3274    /// exactly that case.
3275    pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
3276        let expected = expected.map(str::trim).filter(|head| !head.is_empty());
3277        if let Some(head) = expected {
3278            self.refuse_push_that_drops(cwd, head)?;
3279        }
3280        let refspec = format!("HEAD:{branch}");
3281        let lease = match expected {
3282            Some(head) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
3283            None => "--force-with-lease".to_string(),
3284        };
3285        let pushed = self
3286            .git_at(Some(cwd), &["push", &lease, "origin", &refspec])
3287            .map(|_| ())
3288            .map_err(|e| {
3289                spar_err!(
3290                    "could not push to origin/{branch}. {}\nCheck push access and whether the \
3291                     branch moved under you.",
3292                    e.last_line()
3293                )
3294            });
3295        self.record_write(pushed)
3296    }
3297
3298    /// Refuse a push whose HEAD does not contain the head spar reviewed.
3299    ///
3300    /// The lease answers the remote moving. This answers the local side moving:
3301    /// an agent that reset or rebased the worktree leaves a HEAD that would
3302    /// publish over the reviewed head rather than on top of it, and the lease
3303    /// cannot tell the difference because the remote is exactly where spar left
3304    /// it.
3305    fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
3306        let head = self.head_oid_checked(cwd)?;
3307        if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
3308            return Ok(());
3309        }
3310        bail!(
3311            "refusing to push {head} because it does not contain the reviewed head {expected}. \
3312             The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
3313             the intended commits on top of it before resuming.",
3314            cwd.display()
3315        )
3316    }
3317
3318    /// Create one remote branch for a split without ever moving an existing ref.
3319    ///
3320    /// `worktree_for_split` chooses a name that is free locally and on origin,
3321    /// but another writer can still take it before the push. An empty expected
3322    /// value in the lease makes this an atomic create: it creates an absent ref,
3323    /// accepts an identical ref as a no-op, and never moves an existing ref.
3324    /// The shared `push` method cannot be used because its lease permits
3325    /// updating a ref fetched earlier.
3326    pub fn push_split_branch(
3327        &self,
3328        cwd: &Path,
3329        branch: &str,
3330    ) -> std::result::Result<(), SplitPushError> {
3331        let remote_ref = format!("refs/heads/{branch}");
3332        let lease = format!("--force-with-lease={remote_ref}:");
3333        let refspec = format!("HEAD:{remote_ref}");
3334        let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
3335            Ok(_) => Ok(()),
3336            Err(push_error) => {
3337                let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
3338                let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
3339                reconcile_failed_split_push(branch, push_error, local, remote)
3340            }
3341        };
3342        self.record_write(result)
3343    }
3344
3345    // -- gh ---------------------------------------------------------------
3346
3347    pub fn gh(&self, args: &[&str]) -> Result<String> {
3348        self.gh_at(None, args)
3349    }
3350
3351    pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
3352        let mut argv = vec!["gh".to_string()];
3353        argv.extend(args.iter().map(|s| s.to_string()));
3354        proc::run(
3355            &argv,
3356            &ExecOpts::new()
3357                .cwd(cwd.unwrap_or(&self.root))
3358                .timeout_secs(300),
3359        )
3360    }
3361
3362    /// Run gh with something on its stdin.
3363    ///
3364    /// A tracker body is far too long to pass on argv, and `--body-file -` is
3365    /// how gh takes one. `proc::exec` already wires the pipe, so this is a
3366    /// sibling of `gh_at` rather than anything new.
3367    pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
3368        let mut argv = vec!["gh".to_string()];
3369        argv.extend(args.iter().map(|s| s.to_string()));
3370        proc::run(
3371            &argv,
3372            &ExecOpts::new()
3373                .cwd(&self.root)
3374                .timeout_secs(300)
3375                .stdin(stdin),
3376        )
3377    }
3378
3379    pub fn gh_try(&self, args: &[&str]) -> String {
3380        let mut argv = vec!["gh".to_string()];
3381        argv.extend(args.iter().map(|s| s.to_string()));
3382        proc::run(
3383            &argv,
3384            &ExecOpts::new()
3385                .cwd(&self.root)
3386                .check(false)
3387                .timeout_secs(300),
3388        )
3389        .unwrap_or_default()
3390    }
3391
3392    /// The login `gh` is authenticated as.
3393    ///
3394    /// A hard error, never a degradation. Everything spar wrote has to be
3395    /// excluded from what it answers, and custody cannot be read from git
3396    /// authorship, so this is the only thing that tells spar's own comments
3397    /// from somebody else's. Without it the failure is not "answers a bit too
3398    /// much", it is a thread where spar answers itself until somebody notices.
3399    ///
3400    /// Not cached on disk: `gh auth switch` between runs would make a stored
3401    /// answer wrong in exactly the way that produces that thread.
3402    pub fn viewer_login(&self) -> Result<&str> {
3403        if let Some(login) = self.viewer.get() {
3404            return Ok(login);
3405        }
3406        let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
3407        let login = if !rest.trim().is_empty() {
3408            rest.trim().to_string()
3409        } else {
3410            // A token that cannot read /user can still answer for itself in
3411            // GraphQL, which is the case on some Enterprise installs.
3412            self.gh(&[
3413                "api",
3414                "graphql",
3415                "-f",
3416                "query={ viewer { login } }",
3417                "--jq",
3418                ".data.viewer.login",
3419            ])
3420            .map_err(|e| {
3421                spar_err!(
3422                    "could not find out who `gh` is authenticated as, so spar cannot tell its \
3423                     own comments from anybody else's. {}\nRun `gh auth status`.",
3424                    e.last_line()
3425                )
3426            })?
3427            .trim()
3428            .to_string()
3429        };
3430        if login.is_empty() {
3431            bail!("`gh` reported an empty login. Run `gh auth status`.");
3432        }
3433        Ok(self.viewer.get_or_init(|| login))
3434    }
3435
3436    /// One issue as it stands, open or closed.
3437    ///
3438    /// `fetch_issues` reads a queue to work: it drops a closed issue and fails
3439    /// when nothing survives. Both are wrong for reading one issue back, where
3440    /// closed is an answer and the empty case cannot arise.
3441    pub fn read_issue(&self, number: i64) -> Result<Issue> {
3442        let text = self
3443            .gh(&[
3444                "issue",
3445                "view",
3446                &number.to_string(),
3447                "--json",
3448                "number,title,body,labels,state,stateReason,url",
3449            ])
3450            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3451        serde_json::from_str(&text)
3452            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
3453    }
3454
3455    pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
3456        let mut issues = Vec::new();
3457        for number in numbers {
3458            let text = self
3459                .gh(&[
3460                    "issue",
3461                    "view",
3462                    &number.to_string(),
3463                    "--json",
3464                    "number,title,body,labels,state,stateReason,url",
3465                ])
3466                .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3467            let issue: Issue = serde_json::from_str(&text)
3468                .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3469            if issue.is_closed() {
3470                crate::log!("issue #{number} is closed, skipping");
3471                continue;
3472            }
3473            issues.push(issue);
3474        }
3475        if issues.is_empty() {
3476            bail!("no open issues to work on");
3477        }
3478        Ok(issues)
3479    }
3480
3481    /// Open items, lowest numbered first, from `min_number` upward.
3482    ///
3483    /// The floor exists because a long lived repository accumulates a tail of
3484    /// old issues nobody is going to get to, and taking the lowest numbered
3485    /// open items means walking straight into them.
3486    fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3487        #[derive(Deserialize)]
3488        struct Row {
3489            number: i64,
3490        }
3491        let text = self.gh(&[
3492            kind,
3493            "list",
3494            "--state",
3495            "open",
3496            "--limit",
3497            &FETCH_CEILING.to_string(),
3498            "--json",
3499            "number",
3500        ])?;
3501        let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
3502        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
3503        numbers.sort_unstable();
3504
3505        let noun = if kind == "issue" { "issues" } else { "PRs" };
3506        let found = numbers.len();
3507        if min_number > 0 {
3508            numbers.retain(|n| *n >= min_number);
3509            let skipped = found - numbers.len();
3510            if skipped > 0 {
3511                crate::log!("{skipped} open {noun} below #{min_number} skipped");
3512            }
3513        }
3514        if found >= FETCH_CEILING {
3515            crate::log!(
3516                "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
3517                 considered."
3518            );
3519        }
3520        if numbers.len() > limit {
3521            crate::log!(
3522                "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
3523                 explicitly.",
3524                numbers.len()
3525            );
3526            numbers.truncate(limit);
3527        }
3528        Ok(numbers)
3529    }
3530
3531    /// Open issues, lowest numbered first. `gh issue list` excludes PRs.
3532    pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3533        self.open_numbers("issue", limit, min_number)
3534    }
3535
3536    pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3537        self.open_numbers("pr", limit, min_number)
3538    }
3539
3540    pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
3541        self.branch_prs(branch, "open").into_iter().next()
3542    }
3543
3544    /// The open pull request for a branch, preserving a failed lookup as an
3545    /// error when the caller is deciding whether a write already landed.
3546    pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
3547        let text = self.gh(&[
3548            "pr",
3549            "list",
3550            "--head",
3551            branch,
3552            "--base",
3553            base,
3554            "--state",
3555            "open",
3556            "--json",
3557            "number,url,title,baseRefName",
3558        ])?;
3559        pr_for_base(&text, branch, base)
3560    }
3561
3562    /// Every pull request opened from this branch, merged and closed ones
3563    /// included, because a commit is preserved by whichever one carries it and
3564    /// that is rarely the newest.
3565    fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
3566        self.branch_prs(branch, "all")
3567    }
3568
3569    fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
3570        let text = self.gh_try(&[
3571            "pr",
3572            "list",
3573            "--head",
3574            branch,
3575            "--state",
3576            state,
3577            "--json",
3578            "number,url,title",
3579        ]);
3580        serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
3581    }
3582
3583    /// Whether a number names an issue or a pull request.
3584    ///
3585    /// `gh issue view` happily returns a pull request when handed its number,
3586    /// so it cannot be used to tell them apart. The issues API carries both and
3587    /// marks a pull request with a `pull_request` key, which is definitive.
3588    pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
3589        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
3590        let text = self
3591            .gh(&[
3592                "api",
3593                &path,
3594                "--jq",
3595                "if .pull_request then \"pr\" else \"issue\" end",
3596            ])
3597            .map_err(|e| {
3598                spar_err!(
3599                    "no issue or pull request #{number} in this repository. {}",
3600                    e.last_line()
3601                )
3602            })?;
3603        match text.trim() {
3604            "pr" => Ok(ItemKind::Pr),
3605            "issue" => Ok(ItemKind::Issue),
3606            other => Err(spar_err!(
3607                "could not tell whether #{number} is an issue or a pull request (got {other:?})"
3608            )),
3609        }
3610    }
3611
3612    /// An open pull request that would close this issue, whoever opened it.
3613    ///
3614    /// spar's own branch naming is checked first because it is exact and cheap.
3615    /// Falling back to GitHub's own issue linkage is what lets spar pick up a
3616    /// pull request a person started on a branch named anything at all.
3617    ///
3618    /// An error rather than `None` when the question could not be asked. The
3619    /// two answers are not interchangeable: "no pull request exists" is the one
3620    /// that makes spar implement over the top of somebody's work and open a
3621    /// second pull request for the same issue.
3622    pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
3623        let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
3624        if let Some(pr) = mine {
3625            return Ok(Some(pr));
3626        }
3627        let listed = match self.gh(&[
3628            "pr",
3629            "list",
3630            "--state",
3631            "open",
3632            "--limit",
3633            &FETCH_CEILING.to_string(),
3634            "--json",
3635            "number,url,title,baseRefName,closingIssuesReferences",
3636        ]) {
3637            Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
3638                spar_err!("could not read the pull request list while looking for #{issue}")
3639            })?,
3640            Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
3641        };
3642        if listed.len() >= FETCH_CEILING {
3643            logwarn!(
3644                "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a                  pull request past it cannot be seen from here."
3645            );
3646        }
3647        let default = self.default_branch(base);
3648        if !base.eq_ignore_ascii_case(&default) {
3649            // GitHub populates closingIssuesReferences only for pull requests
3650            // targeting the default branch, so on any other base the linkage is
3651            // silence rather than an answer.
3652            logwarn!(
3653                "#{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."
3654            );
3655        }
3656        Ok(pick_linked_pr(&listed, issue))
3657    }
3658
3659    /// spar's own branch, with a failed lookup kept as a failure.
3660    fn pr_for_branch_failing_closed(
3661        &self,
3662        branch: &str,
3663        base: &str,
3664        issue: i64,
3665    ) -> Result<Option<PrRef>> {
3666        match self.try_pr_for_branch(branch, base) {
3667            Ok(found) => Ok(found),
3668            Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
3669        }
3670    }
3671
3672    /// What to do when GitHub would not say whether a pull request exists.
3673    ///
3674    /// An error, because "no pull request exists" is the answer that makes spar
3675    /// implement from scratch and open a second pull request over somebody's
3676    /// work. The one exception is a repository gh cannot reach at all: there is
3677    /// no pull request to collide with when there is no GitHub repository in
3678    /// play, and every later write would fail anyway. That case is a warning and
3679    /// a `None`, so a run against a plain git remote behaves as it always did.
3680    fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
3681        if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
3682            return Err(spar_err!(
3683                "could not check whether a pull request already exists for #{issue}: {}. \
3684                 Refusing to implement over the top of work that may already be open.",
3685                e.last_line()
3686            ));
3687        }
3688        logwarn!(
3689            "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
3690             whether a pull request already exists for it.",
3691            e.last_line()
3692        );
3693        Ok(())
3694    }
3695
3696    pub fn pr_view(&self, number: i64) -> Result<PrView> {
3697        let text = self.gh(&[
3698            "pr",
3699            "view",
3700            &number.to_string(),
3701            "--json",
3702            "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
3703        ])?;
3704        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
3705    }
3706
3707    fn try_pr_state(&self, number: i64) -> Result<String> {
3708        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
3709        serde_json::from_str::<Value>(text.trim())
3710            .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
3711            .get("state")
3712            .and_then(Value::as_str)
3713            .map(str::to_string)
3714            .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
3715    }
3716
3717    pub fn pr_state(&self, number: i64) -> String {
3718        self.try_pr_state(number).unwrap_or_default()
3719    }
3720
3721    /// The commit currently exposed as a pull request's head.
3722    pub fn pr_head_oid(&self, number: i64) -> Result<String> {
3723        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
3724        let oid = serde_json::from_str::<Value>(&text)
3725            .ok()
3726            .and_then(|value| {
3727                value
3728                    .get("headRefOid")
3729                    .and_then(Value::as_str)
3730                    .map(str::trim)
3731                    .filter(|oid| !oid.is_empty())
3732                    .map(str::to_string)
3733            })
3734            .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
3735        Ok(oid)
3736    }
3737
3738    pub fn create_pr(
3739        &self,
3740        cwd: &Path,
3741        branch: &str,
3742        base: &str,
3743        title: &str,
3744        body: &str,
3745    ) -> Result<PrRef> {
3746        let title = self.record_failed_write(self.clean_title(title))?;
3747        let body = self.record_failed_write(self.clean(body))?;
3748        let mut argv = vec![
3749            "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
3750        ];
3751        if self.drafts != Drafts::Never {
3752            argv.push("--draft");
3753        }
3754        let created = self.gh_at(Some(cwd), &argv);
3755        let found = self.try_pr_for_branch(branch, base);
3756        self.record_write(reconcile_pr_creation(branch, created, found))
3757    }
3758
3759    pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
3760        let body = signed(&self.record_failed_write(self.clean(body))?);
3761        let comments = self.record_failed_write(self.try_issue_comments(number))?;
3762        if has_exact_comment(&comments, &body) {
3763            return Ok(());
3764        }
3765        let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
3766        let result = match posted {
3767            Ok(_) => Ok(()),
3768            Err(post_error) => {
3769                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3770            }
3771        };
3772        self.record_write(result)
3773    }
3774
3775    pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
3776        let body = signed(&self.record_failed_write(self.clean(body))?);
3777        let comments = self.record_failed_write(self.try_issue_comments(number))?;
3778        if has_exact_comment(&comments, &body) {
3779            return Ok(());
3780        }
3781        let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
3782        let result = match posted {
3783            Ok(_) => Ok(()),
3784            Err(post_error) => {
3785                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3786            }
3787        };
3788        self.record_write(result)
3789    }
3790
3791    /// Comment, then close as not planned.
3792    ///
3793    /// Only ever called when both agents independently declined the issue: one
3794    /// agent's opinion is not enough to close somebody's report.
3795    pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
3796        self.comment_issue(number, body)?;
3797        let n = number.to_string();
3798        let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
3799            Ok(_) => Ok(()),
3800            // Older gh builds do not take --reason.
3801            Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
3802                spar_err!(
3803                    "commented on #{number} but could not close it: {}",
3804                    e.last_line()
3805                )
3806            }),
3807        };
3808        self.record_write(closed)
3809    }
3810
3811    /// Replace an issue body, refusing unless it is still byte for byte what
3812    /// the caller read and validating only the fragment spar inserted.
3813    ///
3814    /// The only place spar rewrites text somebody else wrote, so the check is
3815    /// the whole point: an edit computed from a body that has since moved would
3816    /// silently delete whatever moved it. The caller decides whether another
3817    /// attempt is safe for its workflow.
3818    ///
3819    /// Deliberately not through `clean_issue_body`. The body is mostly a
3820    /// person's own prose, and the scrub would rewrite their punctuation while
3821    /// the length budget could truncate the end of a long report. `inserted` is
3822    /// the only text here spar is answerable for, so it still passes through the
3823    /// style gate. The full body travels over stdin because a tracker can be far
3824    /// too long for one argument.
3825    pub fn edit_issue_body(
3826        &self,
3827        number: i64,
3828        expected: &str,
3829        body: &str,
3830        inserted: &str,
3831    ) -> Result<()> {
3832        let cleaned = self.record_failed_write(self.clean(inserted))?;
3833        if cleaned.trim() != inserted.trim() {
3834            return self.record_failed_write(Err(spar_err!(
3835                "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
3836            )));
3837        }
3838        let current = self.record_failed_write(self.issue_body(number))?;
3839        if current != expected {
3840            return self.record_failed_write(Err(spar_err!(
3841                "the body of #{number} changed since it was read, so it was left alone rather \
3842                 than written over."
3843            )));
3844        }
3845        let edited = self.gh_stdin(
3846            &["issue", "edit", &number.to_string(), "--body-file", "-"],
3847            body,
3848        );
3849        let result = match edited {
3850            Ok(_) => Ok(()),
3851            Err(edit_error) => {
3852                reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
3853            }
3854        };
3855        self.record_write(result)
3856    }
3857
3858    /// One issue's body, exactly as GitHub holds it.
3859    pub fn issue_body(&self, number: i64) -> Result<String> {
3860        #[derive(Deserialize)]
3861        struct Row {
3862            #[serde(default)]
3863            body: Option<String>,
3864        }
3865        let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
3866        let row: Row = serde_json::from_str(text.trim())
3867            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3868        Ok(row.body.unwrap_or_default())
3869    }
3870
3871    /// Every open issue with its title and body, in one call.
3872    ///
3873    /// For a screen that has to say something about each of twenty items before
3874    /// anything expensive happens. One call rather than one per issue.
3875    pub fn open_issue_rows(&self) -> Vec<Issue> {
3876        let text = self.gh_try(&[
3877            "issue",
3878            "list",
3879            "--state",
3880            "open",
3881            "--limit",
3882            &FETCH_CEILING.to_string(),
3883            "--json",
3884            "number,title,body,labels,state,stateReason,url",
3885        ]);
3886        serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
3887    }
3888
3889    /// Every open pull request with its size, in one call.
3890    pub fn open_pr_rows(&self) -> Vec<PrRow> {
3891        let text = self.gh_try(&[
3892            "pr",
3893            "list",
3894            "--state",
3895            "open",
3896            "--limit",
3897            &FETCH_CEILING.to_string(),
3898            "--json",
3899            "number,title,changedFiles,additions,deletions",
3900        ]);
3901        serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
3902    }
3903
3904    pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
3905        self.create_issue_apart_from(title, body, None)
3906    }
3907
3908    pub fn create_issue_apart_from(
3909        &self,
3910        title: &str,
3911        body: &str,
3912        apart_from: Option<i64>,
3913    ) -> Result<String> {
3914        let title = self.record_failed_write(self.clean_title(title))?;
3915        let body = self.record_failed_write(self.clean_issue_body(body))?;
3916        let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
3917        let result = match created {
3918            Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
3919            created => {
3920                let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
3921                reconcile_issue_creation(&title, created, found)
3922            }
3923        };
3924        self.record_write(result)
3925    }
3926}
3927
3928/// An issue that already covers what spar was about to file.
3929#[derive(Debug, Clone)]
3930pub struct ExistingIssue {
3931    pub number: i64,
3932    pub url: String,
3933    pub title: String,
3934    pub body: String,
3935    pub open: bool,
3936}
3937
3938impl Repo {
3939    pub(crate) fn try_exact_issue_apart_from(
3940        &self,
3941        title: &str,
3942        body: &str,
3943        apart_from: Option<i64>,
3944    ) -> Result<Option<ExistingIssue>> {
3945        #[derive(Deserialize)]
3946        #[serde(rename_all = "camelCase")]
3947        struct Row {
3948            number: i64,
3949            #[serde(default)]
3950            title: String,
3951            #[serde(default)]
3952            url: String,
3953            #[serde(default)]
3954            body: Option<String>,
3955            #[serde(default)]
3956            state: String,
3957        }
3958
3959        let text = self.gh(&[
3960            "issue",
3961            "list",
3962            "--state",
3963            "all",
3964            "--limit",
3965            "100",
3966            "--json",
3967            "number,title,url,body,state,stateReason",
3968        ])?;
3969        let rows = serde_json::from_str::<Vec<Row>>(text.trim())
3970            .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
3971        Ok(rows
3972            .into_iter()
3973            .filter(|row| Some(row.number) != apart_from)
3974            .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
3975            .map(|row| ExistingIssue {
3976                number: row.number,
3977                url: row.url,
3978                title: row.title,
3979                body: row.body.unwrap_or_default(),
3980                open: row.state.eq_ignore_ascii_case("open"),
3981            }))
3982    }
3983
3984    /// An issue that already describes this defect, however it was worded.
3985    ///
3986    /// Exact title matching let duplicates through: two agents, or two runs a
3987    /// week apart, never word one defect identically. A real run filed two
3988    /// duplicates that way, and each had to be closed by hand afterwards.
3989    /// Titles alone are too thin to match on, so this compares titles and
3990    /// bodies together.
3991    pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
3992        self.find_similar_issue_apart_from(title, body, None)
3993    }
3994
3995    /// The same search, with one issue that cannot be its own duplicate.
3996    ///
3997    /// A tracker's body quotes every item in its checklist, so searching for an
3998    /// item's words matches the tracker before it matches anything else. That
3999    /// would link an item to the issue it is written in.
4000    /// The search a tracker runs, which wants closed issues too: a checklist
4001    /// item matching a closed issue is a done item that wants a link.
4002    pub fn find_similar_issue_apart_from(
4003        &self,
4004        title: &str,
4005        body: &str,
4006        apart_from: Option<i64>,
4007    ) -> Option<ExistingIssue> {
4008        self.try_similar_issue(title, body, apart_from, true)
4009            .ok()
4010            .flatten()
4011    }
4012
4013    /// The search a filing runs, over open issues only.
4014    pub fn try_find_similar_issue_apart_from(
4015        &self,
4016        title: &str,
4017        body: &str,
4018        apart_from: Option<i64>,
4019    ) -> Result<Option<ExistingIssue>> {
4020        self.try_similar_issue(title, body, apart_from, false)
4021    }
4022
4023    /// The same search, preserving lookup failure for a caller about to write.
4024    fn try_similar_issue(
4025        &self,
4026        title: &str,
4027        body: &str,
4028        apart_from: Option<i64>,
4029        include_closed: bool,
4030    ) -> Result<Option<ExistingIssue>> {
4031        #[derive(Deserialize)]
4032        #[serde(rename_all = "camelCase")]
4033        struct Row {
4034            number: i64,
4035            #[serde(default)]
4036            title: String,
4037            #[serde(default)]
4038            url: String,
4039            #[serde(default)]
4040            body: String,
4041            #[serde(default)]
4042            state: String,
4043        }
4044        if title.trim().is_empty() {
4045            return Ok(None);
4046        }
4047        // Search on the title's own words: GitHub's index is the cheap way to
4048        // narrow the field before comparing properly.
4049        let query: String = title
4050            .chars()
4051            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4052            .take(120)
4053            .collect();
4054        // Open issues only when something is about to be filed. A fuzzy match
4055        // against a closed one is the case worth keeping rather than dropping:
4056        // a defect that was fixed, closed, and has come back is exactly the
4057        // finding a maintainer wants, and "#N covers it and is closed" wrote it
4058        // nowhere. An exact match is still checked against every state, since
4059        // that is a regression of one specific issue rather than a resemblance
4060        // to one, and a tracker still searches closed issues because a
4061        // checklist item matching one is a done item that wants a link.
4062        let state = if include_closed { "all" } else { "open" };
4063        let text = self.gh(&[
4064            "issue",
4065            "list",
4066            "--state",
4067            state,
4068            "--limit",
4069            "100",
4070            "--search",
4071            query.trim(),
4072            "--json",
4073            "number,title,url,body,state,stateReason",
4074        ])?;
4075        let rows: Vec<Row> = serde_json::from_str(text.trim())
4076            .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
4077
4078        Ok(rows
4079            .into_iter()
4080            .filter(|row| Some(row.number) != apart_from)
4081            .find(|row| {
4082                row.title.trim().eq_ignore_ascii_case(title.trim())
4083                    || textsim::covers(title, body, &row.title, &row.body)
4084            })
4085            .map(|row| ExistingIssue {
4086                number: row.number,
4087                url: row.url,
4088                title: row.title,
4089                open: row.state.eq_ignore_ascii_case("open"),
4090                body: row.body,
4091            }))
4092    }
4093
4094    /// Avoid filing a duplicate when a follow-up already exists.
4095    pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
4096        #[derive(Deserialize)]
4097        struct Row {
4098            title: String,
4099            url: String,
4100        }
4101        let needle = title.trim().to_lowercase();
4102        if needle.is_empty() {
4103            return None;
4104        }
4105        // Quotes and newlines would be read as search syntax rather than text.
4106        let query: String = title
4107            .chars()
4108            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4109            .take(120)
4110            .collect();
4111        let text = self.gh_try(&[
4112            "issue",
4113            "list",
4114            "--state",
4115            "all",
4116            "--limit",
4117            "100",
4118            "--search",
4119            query.trim(),
4120            "--json",
4121            "number,title,url",
4122        ]);
4123        serde_json::from_str::<Vec<Row>>(text.trim())
4124            .ok()?
4125            .into_iter()
4126            .find(|row| row.title.trim().to_lowercase() == needle)
4127            .map(|row| row.url)
4128    }
4129
4130    /// Squash merge, tolerating cleanup failures after a successful merge.
4131    ///
4132    /// Take a pull request out of draft, once the review has converged.
4133    ///
4134    /// Best effort for the remaining workflow. A failure does not discard the
4135    /// review or stop later independent work, but the final write summary
4136    /// reports it and the command returns non-zero.
4137    pub fn mark_ready(&self, number: i64) -> bool {
4138        match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
4139            Ok(_) => true,
4140            Err(e) => {
4141                logdim!(
4142                    "PR #{number} is approved but could not be taken out of draft: {}",
4143                    e.last_line()
4144                );
4145                false
4146            }
4147        }
4148    }
4149
4150    /// The worktree that has `branch` checked out, if one does.
4151    fn worktree_holding(&self, branch: &str) -> Option<PathBuf> {
4152        let listed = self
4153            .git_at(Some(&self.root), &["worktree", "list", "--porcelain"])
4154            .ok()?;
4155        let wanted = format!("branch refs/heads/{branch}");
4156        let mut current: Option<PathBuf> = None;
4157        for line in listed.lines() {
4158            if let Some(path) = line.strip_prefix("worktree ") {
4159                current = Some(PathBuf::from(path));
4160            } else if line == wanted {
4161                return current;
4162            }
4163        }
4164        None
4165    }
4166
4167    /// Finish the local cleanup a merge could not.
4168    ///
4169    /// `gh pr merge --delete-branch` gives up on the local branch whenever any
4170    /// worktree has it checked out, which is the ordinary state rather than an
4171    /// odd one: `spar run` opens the pull request from
4172    /// `.spar-worktrees/issue-N` and leaves the checkout there, and the merge
4173    /// usually happens in a later `spar resume` working somewhere else. The
4174    /// next run's sweep tidied it, so nothing was ever lost, but the run that
4175    /// did the merge reported a failure about a branch nobody will touch again
4176    /// and left a checkout for the next run to explain.
4177    ///
4178    /// Held to the same rules the sweep uses, because they are the rules that
4179    /// decide whether anything would be lost: a branch spar recorded creating,
4180    /// a worktree of spar's own with no recoverable work in it, and a tip some
4181    /// surviving ref preserves. Anything else is left exactly where it is.
4182    fn finish_merged_branch_cleanup(&self, number: i64) -> Option<String> {
4183        let branch = self.pr_view(number).ok()?.head_ref_name;
4184        if branch.is_empty() || !self.known_branches().contains_key(&branch) {
4185            return None;
4186        }
4187        let mut released = None;
4188        if let Some(path) = self.worktree_holding(&branch) {
4189            if !path.starts_with(self.root.join(WORKTREE_DIR)) {
4190                return None;
4191            }
4192            if !matches!(
4193                self.has_recoverable_work(&path, Disposable::BuildOutput),
4194                Ok(false)
4195            ) {
4196                return None;
4197            }
4198            match self.remove_worktree_at(&path) {
4199                Ok(true) => released = Some(path),
4200                _ => return None,
4201            }
4202        }
4203        match self.delete_branch_if_safe(&branch) {
4204            Ok(true) => {
4205                self.forget_branch(&branch);
4206                Some(match released {
4207                    Some(path) => format!(
4208                        " The worktree at {} was released and branch {branch} deleted.",
4209                        path.display()
4210                    ),
4211                    None => format!(" Branch {branch} was deleted."),
4212                })
4213            }
4214            _ => released.map(|path| {
4215                format!(
4216                    " The worktree at {} was released, but branch {branch} was kept.",
4217                    path.display()
4218                )
4219            }),
4220        }
4221    }
4222
4223    /// `gh pr merge --delete-branch` exits non-zero when it cannot delete the
4224    /// local branch, which happens *after* the merge has already landed.
4225    /// Treating that as a failure reports work as lost when it is not.
4226    pub fn merge_pr(&self, number: i64) -> Result<()> {
4227        let n = number.to_string();
4228        let merged = match self.gh(&merge_pr_args(&n, None, true)) {
4229            Ok(_) => Ok(()),
4230            Err(e) => {
4231                if self.pr_state(number) == "MERGED" {
4232                    let finished = self
4233                        .finish_merged_branch_cleanup(number)
4234                        .unwrap_or_default();
4235                    logdim!(
4236                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
4237                        e.last_line()
4238                    );
4239                    Ok(())
4240                } else {
4241                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4242                }
4243            }
4244        };
4245        self.record_write(merged)
4246    }
4247
4248    /// Squash merge only if the pull request still exposes the reviewed head.
4249    pub fn merge_pr_at_head(
4250        &self,
4251        number: i64,
4252        expected_head: &str,
4253        delete_branch: bool,
4254    ) -> Result<()> {
4255        let n = number.to_string();
4256        let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
4257            Ok(_) => Ok(()),
4258            Err(e) => {
4259                if self.pr_state(number) == "MERGED" {
4260                    let finished = self
4261                        .finish_merged_branch_cleanup(number)
4262                        .unwrap_or_default();
4263                    logdim!(
4264                        "PR #{number} merged; branch cleanup did not finish: {}{finished}",
4265                        e.last_line()
4266                    );
4267                    Ok(())
4268                } else {
4269                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4270                }
4271            }
4272        };
4273        self.record_write(merged)
4274    }
4275
4276    // -- follow-ups -------------------------------------------------------
4277
4278    /// The queue of follow-ups recorded locally rather than filed, which
4279    /// `spar followup` works.
4280    pub fn followups_path(&self) -> PathBuf {
4281        self.root.join(STATE_DIR).join("followups.md")
4282    }
4283
4284    /// Where `spar brainstorm` writes a session unless told otherwise.
4285    ///
4286    /// Under the state directory, so it is kept out of the repository's own
4287    /// `git status`; `--out` puts one somewhere a person would commit.
4288    pub fn brainstorms_dir(&self) -> PathBuf {
4289        self.root.join(STATE_DIR).join("brainstorms")
4290    }
4291
4292    /// What `spar followup` already dealt with, kept beside the queue.
4293    ///
4294    /// Two jobs. It is what stops `append_local_followup` re-recording a
4295    /// follow-up whose entry has since left the queue, which would otherwise
4296    /// turn the file into a ring buffer of things already filed. And it keeps
4297    /// the text of an entry a screening pass ruled stale, so a wrong verdict
4298    /// costs a re-read rather than the only copy of a real defect.
4299    pub fn worked_followups_path(&self) -> PathBuf {
4300        self.root.join(STATE_DIR).join("followups.done.md")
4301    }
4302
4303    /// What `spar checkin` has already answered on one pull request or issue.
4304    /// Where what a run spent is kept.
4305    pub fn spend_path(&self) -> PathBuf {
4306        self.root.join(STATE_DIR).join("state").join("spend.json")
4307    }
4308
4309    /// Append this run's calls to the record.
4310    ///
4311    /// Appended rather than replaced, so a resumed pull request adds to the
4312    /// count of what it has cost so far rather than starting it again, and so
4313    /// anybody charting this has a file to chart.
4314    pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
4315        if calls.is_empty() {
4316            return Ok(());
4317        }
4318        let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
4319            .ok()
4320            .and_then(|text| serde_json::from_str(&text).ok())
4321            .unwrap_or_default();
4322        all.extend(calls.iter().cloned());
4323        if let Some(parent) = self.spend_path().parent() {
4324            let _ = std::fs::create_dir_all(parent);
4325        }
4326        write_json_atomic(&self.spend_path(), &all)
4327    }
4328
4329    /// Who wrote the first draft of each issue, by number.
4330    ///
4331    /// Custody alternates within a pull request and the record of it lives in
4332    /// that pull request's state. This is the other question, which nothing
4333    /// answered: over a queue, which model authored what.
4334    pub fn implementors_path(&self) -> PathBuf {
4335        self.root
4336            .join(STATE_DIR)
4337            .join("state")
4338            .join("implementors.json")
4339    }
4340
4341    pub fn read_implementors(&self) -> BTreeMap<i64, String> {
4342        std::fs::read_to_string(self.implementors_path())
4343            .ok()
4344            .and_then(|text| serde_json::from_str(&text).ok())
4345            .unwrap_or_default()
4346    }
4347
4348    /// Best effort: this is a record for a person, not something the loop reads
4349    /// back, so a failure to write it is not a reason to stop.
4350    pub fn remember_implementor(&self, issue: i64, agent: &str) {
4351        let mut known = self.read_implementors();
4352        known.insert(issue, agent.to_string());
4353        if let Some(parent) = self.implementors_path().parent() {
4354            let _ = std::fs::create_dir_all(parent);
4355        }
4356        if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
4357            logdim!("could not record who implemented #{issue}: {e}");
4358        }
4359    }
4360
4361    /// Where the issues triage disagreed about are remembered.
4362    ///
4363    /// One file rather than one per issue: it is read once per run, before any
4364    /// triage call, to decide what not to ask about again.
4365    pub fn contested_path(&self) -> PathBuf {
4366        self.root
4367            .join(STATE_DIR)
4368            .join("state")
4369            .join("contested.json")
4370    }
4371
4372    /// The issues a person has been asked to decide, by number.
4373    pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
4374        std::fs::read_to_string(self.contested_path())
4375            .ok()
4376            .and_then(|text| serde_json::from_str(&text).ok())
4377            .unwrap_or_default()
4378    }
4379
4380    /// Remember an issue triage could not agree on.
4381    ///
4382    /// Best effort: a run that cannot write this asks again next time, which is
4383    /// what it did before this existed.
4384    pub fn remember_contested(&self, items: &[ContestedItem]) {
4385        if items.is_empty() {
4386            return;
4387        }
4388        let mut known = self.read_contested();
4389        for item in items {
4390            known.insert(
4391                item.issue,
4392                Contested {
4393                    title: item.title.clone(),
4394                    positions: item.positions.clone(),
4395                    reasons: item.reasons.clone(),
4396                },
4397            );
4398        }
4399        if let Some(parent) = self.contested_path().parent() {
4400            let _ = std::fs::create_dir_all(parent);
4401        }
4402        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4403            logdim!("could not record the contested issues: {e}");
4404        }
4405    }
4406
4407    /// Ask about them again, because a person has acted on them.
4408    pub fn forget_contested(&self, numbers: &[i64]) {
4409        let mut known = self.read_contested();
4410        let before = known.len();
4411        known.retain(|number, _| !numbers.contains(number));
4412        if known.len() == before {
4413            return;
4414        }
4415        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4416            logdim!("could not update the contested issues: {e}");
4417        }
4418    }
4419
4420    pub fn checkin_state_path(&self, number: i64) -> PathBuf {
4421        self.root
4422            .join(STATE_DIR)
4423            .join("state")
4424            .join(format!("checkin-{number}.json"))
4425    }
4426
4427    /// Append a follow-up to a local note instead of the tracker.
4428    ///
4429    /// Deduplicated on the title, matching the issue path. The body arrives
4430    /// with its provenance already stamped by the caller, so nothing is added
4431    /// here.
4432    ///
4433    /// A write that did not happen is reported as such rather than as a
4434    /// duplicate: the caller settles the point on the strength of this answer,
4435    /// and settling it on a failed write is how a real defect is lost.
4436    ///
4437    /// Both files are checked, because `spar followup` removes an entry from
4438    /// the queue once it has filed it. Checking only the queue would let the
4439    /// next run that rediscovers the same defect append it again, on top of the
4440    /// issue that now exists for it.
4441    pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
4442        let path = self.followups_path();
4443        let heading = format!("## {}", title.trim());
4444        for seen in [&path, &self.worked_followups_path()] {
4445            if let Ok(existing) = std::fs::read_to_string(seen) {
4446                if has_heading(&existing, title) {
4447                    logdim!("follow-up already noted: {title}");
4448                    return Followup::Covered(format!("note: {}", title.trim()));
4449                }
4450            }
4451        }
4452        if let Some(parent) = path.parent() {
4453            let _ = std::fs::create_dir_all(parent);
4454        }
4455        use std::io::Write;
4456        // The caller already stamped the provenance into the body. Adding
4457        // "From #N." here as well printed it twice, in two different wordings.
4458        //
4459        // The marker above the heading is what makes the entry boundary
4460        // unambiguous to the parser, since the body's own sections are written
4461        // at the same heading level as the title.
4462        let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
4463        match std::fs::OpenOptions::new()
4464            .create(true)
4465            .append(true)
4466            .open(&path)
4467        {
4468            Ok(mut file) => match file.write_all(entry.as_bytes()) {
4469                Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
4470                Err(e) => {
4471                    logdim!("could not write {}: {e}", path.display());
4472                    Followup::Failed
4473                }
4474            },
4475            Err(e) => {
4476                logdim!("could not write {}: {e}", path.display());
4477                Followup::Failed
4478            }
4479        }
4480    }
4481
4482    /// Record what `spar followup` did with an entry, and why.
4483    ///
4484    /// Best effort: an archive that could not be written is not a reason to
4485    /// stop, since the entry has already been filed or ruled on.
4486    pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
4487        let path = self.worked_followups_path();
4488        if let Some(parent) = path.parent() {
4489            let _ = std::fs::create_dir_all(parent);
4490        }
4491        use std::io::Write;
4492        let entry = format!(
4493            "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
4494            title.trim(),
4495            body.trim()
4496        );
4497        if let Ok(mut file) = std::fs::OpenOptions::new()
4498            .create(true)
4499            .append(true)
4500            .open(&path)
4501        {
4502            let _ = file.write_all(entry.as_bytes());
4503        }
4504    }
4505
4506    // -- resumable state --------------------------------------------------
4507    //
4508    // Custody cannot be read from GitHub authorship: every agent commits and
4509    // comments as the same git identity, so `author` is always the human who
4510    // ran spar. State is kept on disk by default and can additionally travel in
4511    // a PR comment, which is what lets a run be resumed from another machine.
4512
4513    /// Where a comment spar produced but did not post is kept.
4514    pub fn pending_comment_path(&self, number: i64) -> PathBuf {
4515        self.root
4516            .join(STATE_DIR)
4517            .join("reviews")
4518            .join(format!("pr-{number}.md"))
4519    }
4520
4521    /// Keep a comment spar decided not to post.
4522    ///
4523    /// A dry run that prints and forgets means agreeing with what you read
4524    /// costs a second full review. Saving it makes the whole point of reading
4525    /// it first: look, edit if you like, then post what you already paid for.
4526    pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
4527        let path = self.pending_comment_path(number);
4528        if let Some(parent) = path.parent() {
4529            std::fs::create_dir_all(parent)
4530                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
4531        }
4532        std::fs::write(&path, text)
4533            .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4534        Ok(path)
4535    }
4536
4537    pub fn read_pending_comment(&self, number: i64) -> Option<String> {
4538        std::fs::read_to_string(self.pending_comment_path(number)).ok()
4539    }
4540
4541    pub fn state_path(&self, number: i64) -> PathBuf {
4542        self.root
4543            .join(STATE_DIR)
4544            .join("state")
4545            .join(format!("pr-{number}.json"))
4546    }
4547
4548    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
4549        let path = self.state_path(number);
4550        let text = std::fs::read_to_string(&path).ok()?;
4551        match serde_json::from_str(&text) {
4552            Ok(state) => Some(state),
4553            Err(_) => {
4554                logdim!("could not read {}, starting fresh", path.display());
4555                None
4556            }
4557        }
4558    }
4559
4560    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
4561        if let Some(local) = self.read_local_state(pr.number) {
4562            return Some(local);
4563        }
4564        if self.state_store.writes_pr() {
4565            return self.read_pr_state(pr.number);
4566        }
4567        None
4568    }
4569
4570    pub(crate) fn read_state_for_head(
4571        &self,
4572        pr: &PrView,
4573        actual_head: &str,
4574    ) -> Option<PersistedState> {
4575        let local = self
4576            .state_store
4577            .writes_local()
4578            .then(|| self.read_local_state(pr.number))
4579            .flatten();
4580        let remote = self
4581            .state_store
4582            .writes_pr()
4583            .then(|| self.read_pr_state(pr.number))
4584            .flatten();
4585        let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
4586        if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
4587            self.remember_checkpoint(pr.number, checkpoint);
4588        }
4589        choose_state_for_head(candidates, actual_head)
4590    }
4591
4592    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
4593        self.try_read_pr_state(number).ok().flatten()
4594    }
4595
4596    fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
4597        for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
4598            if let Some(state) = parse_state_comment(&body) {
4599                return Ok(Some(state));
4600            }
4601        }
4602        Ok(None)
4603    }
4604
4605    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4606        let remote_state = if self.state_store.writes_pr() {
4607            self.try_read_pr_state(number)
4608        } else {
4609            Ok(None)
4610        };
4611        self.write_state_after_remote_read(number, state, remote_state)
4612    }
4613
4614    fn write_state_after_remote_read(
4615        &self,
4616        number: i64,
4617        state: &PersistedState,
4618        remote_state: Result<Option<PersistedState>>,
4619    ) -> Result<()> {
4620        let remote_checkpoint = if self.state_store.writes_pr() {
4621            self.record_failed_write(remote_state)?
4622                .map(|saved| saved.checkpoint)
4623                .unwrap_or_default()
4624        } else {
4625            0
4626        };
4627        let local_checkpoint = self
4628            .state_store
4629            .writes_local()
4630            .then(|| self.read_local_state(number))
4631            .flatten()
4632            .map(|saved| saved.checkpoint)
4633            .unwrap_or_default();
4634        let mut stamped = state.clone();
4635        stamped.checkpoint = state
4636            .checkpoint
4637            .max(local_checkpoint)
4638            .max(remote_checkpoint)
4639            .max(self.remembered_checkpoint(number))
4640            .saturating_add(1);
4641        self.remember_checkpoint(number, stamped.checkpoint);
4642        if self.state_store.writes_local() {
4643            write_json_atomic(&self.state_path(number), &stamped)?;
4644        }
4645        if self.state_store.writes_pr() {
4646            self.write_pr_state(number, &stamped)?;
4647        }
4648        Ok(())
4649    }
4650
4651    fn remembered_checkpoint(&self, number: i64) -> u64 {
4652        self.checkpoints
4653            .lock()
4654            .unwrap_or_else(std::sync::PoisonError::into_inner)
4655            .get(&number)
4656            .copied()
4657            .unwrap_or_default()
4658    }
4659
4660    fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
4661        let mut checkpoints = self
4662            .checkpoints
4663            .lock()
4664            .unwrap_or_else(std::sync::PoisonError::into_inner);
4665        let saved = checkpoints.entry(number).or_default();
4666        *saved = (*saved).max(checkpoint);
4667    }
4668
4669    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4670        // Not run through clean(): this is structured data, and scrubbing would
4671        // corrupt refutation text stored in the ledger. It sits inside an
4672        // unclosed HTML comment so GitHub renders it as nothing.
4673        let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
4674        let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
4675        let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
4676        if let Some(id) = comment_id {
4677            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4678            let field = format!("body={body}");
4679            let written = self
4680                .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
4681                .map(|_| ());
4682            return self.record_write(written);
4683        }
4684        let written = self
4685            .gh(&["pr", "comment", &number.to_string(), "--body", &body])
4686            .map(|_| ());
4687        self.record_write(written)
4688    }
4689
4690    /// Top level comments. Works for issues and pull requests alike, because
4691    /// GitHub serves both from the issues endpoint.
4692    ///
4693    /// Nothing when they cannot be read, which suits a reader that is going to
4694    /// go on regardless. A caller deciding whether it has already written here
4695    /// wants `try_issue_comments`, since for that one no comments and no answer
4696    /// are opposite answers.
4697    pub fn issue_comments(&self, number: i64) -> Vec<Value> {
4698        self.try_issue_comments(number).unwrap_or_default()
4699    }
4700
4701    pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
4702        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
4703        try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
4704    }
4705
4706    /// spar's own state comments on a pull request, newest last.
4707    ///
4708    /// Three tests, because the answer decides what gets patched over and what
4709    /// gets deleted. A comment matching the marker loosely, which is what
4710    /// somebody asking "why is there a `<!-- spar:state` block on this PR?"
4711    /// writes, is named in the log and left exactly where it is.
4712    fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
4713        let viewer = self.viewer_login()?.to_string();
4714        let mut mine = Vec::new();
4715        let mut theirs = Vec::new();
4716        for comment in self.try_issue_comments(number)? {
4717            let Some(body) = comment.get("body").and_then(Value::as_str) else {
4718                continue;
4719            };
4720            if !body.contains("spar:state") {
4721                continue;
4722            }
4723            let Some(id) = comment.get("id").and_then(Value::as_i64) else {
4724                continue;
4725            };
4726            let author = comment
4727                .get("user")
4728                .and_then(|u| u.get("login"))
4729                .and_then(Value::as_str)
4730                .unwrap_or_default();
4731            if is_state_comment(body, author, &viewer) {
4732                mine.push((id, body.to_string()));
4733            } else {
4734                theirs.push(format!("@{author}"));
4735            }
4736        }
4737        if !theirs.is_empty() {
4738            logdim!(
4739                "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
4740                 own state block is patched or deleted.",
4741                theirs.len(),
4742                theirs.join(", ")
4743            );
4744        }
4745        Ok(mine)
4746    }
4747
4748    fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
4749        Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
4750    }
4751
4752    /// Drop state once the PR is finished and there is nothing to resume.
4753    pub fn clear_state(&self, number: i64) {
4754        let path = self.state_path(number);
4755        let _ = std::fs::remove_file(&path);
4756        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
4757    }
4758
4759    // -- housekeeping -----------------------------------------------------
4760
4761    /// Remove state files whose PR is merged or closed.
4762    pub fn prune_state(&self) -> Vec<String> {
4763        let base = self.root.join(STATE_DIR).join("state");
4764        let Ok(entries) = std::fs::read_dir(&base) else {
4765            return Vec::new();
4766        };
4767        let mut names: Vec<String> = entries
4768            .flatten()
4769            .filter_map(|e| e.file_name().to_str().map(str::to_string))
4770            .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
4771            .collect();
4772        names.sort();
4773
4774        let mut removed = Vec::new();
4775        for name in names {
4776            let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
4777                continue;
4778            };
4779            if is_finished(&self.pr_state(number)) {
4780                let _ = std::fs::remove_file(base.join(&name));
4781                removed.push(format!("state {name}"));
4782            }
4783        }
4784        removed
4785    }
4786
4787    /// Delete state comments from PRs that are finished.
4788    ///
4789    /// Open PRs are left alone: their state may still be live.
4790    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
4791        #[derive(Deserialize)]
4792        struct Row {
4793            number: i64,
4794        }
4795        let numbers = match numbers {
4796            Some(numbers) => numbers,
4797            None => {
4798                let listed: Result<Vec<i64>> = (|| {
4799                    let text = self.gh(&[
4800                        "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
4801                    ])?;
4802                    let rows = serde_json::from_str::<Vec<Row>>(text.trim())
4803                        .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
4804                    Ok(rows.into_iter().map(|row| row.number).collect())
4805                })();
4806                match self.record_failed_write(listed) {
4807                    Ok(numbers) => numbers,
4808                    Err(e) => {
4809                        logdim!("could not inspect pull requests for state cleanup: {e}");
4810                        return Vec::new();
4811                    }
4812                }
4813            }
4814        };
4815
4816        let mut removed = Vec::new();
4817        for number in numbers {
4818            let state = match self.record_failed_write(self.try_pr_state(number)) {
4819                Ok(state) => state,
4820                Err(e) => {
4821                    logdim!("could not inspect PR #{number} for state cleanup: {e}");
4822                    continue;
4823                }
4824            };
4825            if !is_finished(&state) {
4826                continue;
4827            }
4828            let comments = match self.record_failed_write(self.try_state_comments(number)) {
4829                Ok(comments) => comments,
4830                Err(e) => {
4831                    logdim!("could not inspect state comments on PR #{number}: {e}");
4832                    continue;
4833                }
4834            };
4835            for (id, _) in comments {
4836                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4837                let deleted = self
4838                    .gh(&["api", "-X", "DELETE", &path, "--silent"])
4839                    .map(|_| ());
4840                match self.record_write(deleted) {
4841                    Ok(()) => removed.push(format!("state comment on PR #{number}")),
4842                    Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
4843                }
4844            }
4845        }
4846        removed
4847    }
4848
4849    /// Drop worktrees whose PR is finished, then the branches they left behind.
4850    ///
4851    /// With auto_merge off, which is the default, a run ends at "approved", so
4852    /// nothing would ever clean these up on its own and they accumulate one per
4853    /// run. A stranded worktree also holds its branch checked out, which makes
4854    /// a later `gh pr merge --delete-branch` fail to clean up.
4855    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
4856        let base = self.root.join(WORKTREE_DIR);
4857        let mut removed = Vec::new();
4858        let known = self.known_branches();
4859
4860        if let Ok(entries) = std::fs::read_dir(&base) {
4861            let mut names: Vec<String> = entries
4862                .flatten()
4863                .filter(|e| e.path().is_dir())
4864                .filter_map(|e| e.file_name().to_str().map(str::to_string))
4865                .collect();
4866            names.sort();
4867
4868            for name in names {
4869                // A review worktree is detached and owns no branch, so it is
4870                // tied to the pull request only by its directory name.
4871                if let Some(rest) = name.strip_prefix("review-") {
4872                    let number: i64 = rest.parse().unwrap_or(-1);
4873                    if !(force_all || is_finished(&self.pr_state(number))) {
4874                        continue;
4875                    }
4876                    let path = base.join(&name);
4877                    if force_all {
4878                        let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
4879                            if !belongs {
4880                                return Ok(false);
4881                            }
4882                            let local_ref = review_ref(number);
4883                            if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
4884                                return Ok(false);
4885                            }
4886                            let head = self.head_oid_checked(&path)?;
4887                            let recorded = self
4888                                .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
4889                                .trim()
4890                                .to_string();
4891                            Ok(head == recorded)
4892                        });
4893                        match owned {
4894                            Ok(true) => {}
4895                            Ok(false) => {
4896                                logdim!(
4897                                    "kept {} because no matching SPAR review reference proves \
4898                                     ownership",
4899                                    path.display()
4900                                );
4901                                continue;
4902                            }
4903                            Err(e) => {
4904                                logdim!(
4905                                    "kept {} because review ownership could not be verified: {}",
4906                                    path.display(),
4907                                    e.last_line()
4908                                );
4909                                continue;
4910                            }
4911                        }
4912                    } else {
4913                        if let Err(e) = self.refuse_review_worktree_changes(number) {
4914                            logdim!(
4915                                "kept {} because its review state could not be verified as \
4916                                 disposable: {}",
4917                                path.display(),
4918                                e.last_line()
4919                            );
4920                            continue;
4921                        }
4922                    }
4923                    if force_all {
4924                        if self.remove_worktree_at_force(&path) {
4925                            self.git_try(&["update-ref", "-d", &review_ref(number)]);
4926                        }
4927                    } else {
4928                        self.release_review_worktree(number);
4929                    }
4930                    if !path.exists() {
4931                        removed.push(name);
4932                    }
4933                    continue;
4934                }
4935                let branch = format!("{}{name}", self.branch_prefix);
4936                if !(force_all || self.worktree_is_done(&branch)) {
4937                    continue;
4938                }
4939                if !known.contains_key(&branch) {
4940                    logdim!("kept {branch} because it has no branch record");
4941                    continue;
4942                }
4943                let path = base.join(&name);
4944                if !force_all {
4945                    match self.has_recoverable_work(&path, Disposable::BuildOutput) {
4946                        Ok(true) => {
4947                            logdim!(
4948                                "kept {} because it contains uncommitted changes or ignored files",
4949                                path.display()
4950                            );
4951                            continue;
4952                        }
4953                        Err(e) => {
4954                            logdim!(
4955                                "kept {} because its Git state could not be checked: {}",
4956                                path.display(),
4957                                e.last_line()
4958                            );
4959                            continue;
4960                        }
4961                        Ok(false) => {}
4962                    }
4963                    match self.branch_deletion_is_safe(&branch) {
4964                        Ok(true) => {}
4965                        Ok(false) => {
4966                            logdim!(
4967                                "kept {branch} because no surviving ref preserves its tip or \
4968                                 reflog-only commits"
4969                            );
4970                            continue;
4971                        }
4972                        Err(e) => {
4973                            logdim!(
4974                                "kept {branch} because preservation could not be verified: {}",
4975                                e.last_line()
4976                            );
4977                            continue;
4978                        }
4979                    }
4980                }
4981                let removed_worktree = if force_all {
4982                    self.remove_worktree_at_force(&path)
4983                } else {
4984                    match self.remove_worktree_at(&path) {
4985                        Ok(removed) => removed,
4986                        Err(error) => {
4987                            logdim!(
4988                                "kept {branch} and {} because removal did not reach a confirmed \
4989                                 quiet point: {}",
4990                                path.display(),
4991                                error.last_line()
4992                            );
4993                            false
4994                        }
4995                    }
4996                };
4997                if !removed_worktree {
4998                    continue;
4999                }
5000                if force_all {
5001                    self.git_try(&["branch", "-D", &branch]);
5002                    self.forget_branch(&branch);
5003                } else {
5004                    match self.delete_branch_if_safe(&branch) {
5005                        Ok(true) => self.forget_branch(&branch),
5006                        Ok(false) => logdim!(
5007                            "kept {branch} because its tip or reflog changed before deletion"
5008                        ),
5009                        Err(error) => logdim!(
5010                            "kept {branch} because deletion safety could not be rechecked: {}",
5011                            error.last_line()
5012                        ),
5013                    }
5014                }
5015                removed.push(name);
5016            }
5017        }
5018        removed.extend(self.prune_branches(force_all));
5019        removed
5020    }
5021
5022    /// Delete leftover branches spar created whose worktree is already gone.
5023    ///
5024    /// Deletion is driven by the ledger of branches spar actually created, not
5025    /// by a name pattern. Names default to `issue-N`, which is exactly what a
5026    /// person would call a branch themselves, so a name alone can never
5027    /// establish ownership. This is the data loss guard.
5028    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
5029        let known = self.known_branches();
5030        let branches: Vec<String> = known.keys().cloned().collect();
5031        if branches.is_empty() {
5032            return Vec::new();
5033        }
5034
5035        let checked_out: Vec<String> = self
5036            .git_try(&["worktree", "list", "--porcelain"])
5037            .lines()
5038            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
5039            .collect();
5040
5041        // %(refname:short) is ambiguous when a tag shares the branch name (it
5042        // yields "heads/..."), so take the full ref and strip it here.
5043        let existing: Vec<String> = self
5044            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
5045            .lines()
5046            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
5047            .collect();
5048
5049        let mut removed = Vec::new();
5050        for branch in branches {
5051            if !existing.contains(&branch) {
5052                self.forget_branch(&branch); // already gone, drop the record
5053                continue;
5054            }
5055            if checked_out.contains(&branch) {
5056                continue;
5057            }
5058            if !(force_all || self.worktree_is_done(&branch)) {
5059                continue;
5060            }
5061            if !force_all {
5062                let Some(_record) = known.get(&branch) else {
5063                    continue;
5064                };
5065                match self.branch_deletion_is_safe(&branch) {
5066                    Ok(true) => {}
5067                    Ok(false) => {
5068                        logdim!(
5069                            "kept {branch} because no surviving ref preserves its tip or \
5070                             reflog-only commits"
5071                        );
5072                        continue;
5073                    }
5074                    Err(e) => {
5075                        logdim!(
5076                            "kept {branch} because preservation could not be verified: {}",
5077                            e.last_line()
5078                        );
5079                        continue;
5080                    }
5081                }
5082            }
5083            let deleted = if force_all {
5084                self.git(&["branch", "-D", &branch]).map(|_| true)
5085            } else {
5086                self.delete_branch_if_safe(&branch)
5087            };
5088            match deleted {
5089                Ok(true) => {
5090                    self.forget_branch(&branch);
5091                    removed.push(format!("branch {branch}"));
5092                }
5093                Ok(false) => {
5094                    logdim!("kept {branch} because its tip or reflog changed before deletion");
5095                }
5096                Err(e) => {
5097                    // A branch that silently survives pruning looks like a spar
5098                    // bug, so the name and git's own reason have to be said.
5099                    logdim!("could not delete {branch}: {}", e.last_line());
5100                }
5101            }
5102        }
5103        removed
5104    }
5105
5106    /// True when the PR behind this branch is merged or closed.
5107    fn worktree_is_done(&self, branch: &str) -> bool {
5108        #[derive(Deserialize)]
5109        struct Row {
5110            state: String,
5111        }
5112        let entry = branch
5113            .strip_prefix(self.branch_prefix.as_str())
5114            .unwrap_or(branch);
5115        if let Some(rest) = entry.strip_prefix("pr-") {
5116            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
5117        }
5118        // A split part is the same shape as an issue branch: one branch, whose
5119        // pull requests say whether it is finished. Without it here, a part
5120        // branch is one nothing but `clean --all` would ever remove.
5121        if entry.starts_with("issue-") || entry.starts_with("split-") {
5122            let text = self.gh_try(&[
5123                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
5124            ]);
5125            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
5126            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
5127        }
5128        false
5129    }
5130}
5131
5132// ---------------------------------------------------------------------------
5133// Free helpers
5134// ---------------------------------------------------------------------------
5135
5136/// Take a read-only probe again before treating its failure as evidence.
5137///
5138/// Every probe is a Git invocation under a thirty second cap plus a walk over a
5139/// tree an agent may still be building in. A probe that fails proves nothing
5140/// about the worktree, and there is a real difference between "the tree moved"
5141/// and "the question could not be asked just then", which the caller turns into
5142/// the same run-ending error.
5143pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
5144    match probe() {
5145        Ok(value) => Ok(value),
5146        Err(first) => {
5147            logdim!("retrying a worktree probe after: {}", first.last_line());
5148            probe()
5149        }
5150    }
5151}
5152
5153/// Read attribute files without asking Git to inspect working-tree content.
5154///
5155/// A newly written attribute can select a clean or smudge filter. It must be
5156/// detected before a post-call status, diff, or add command has a chance to run
5157/// that filter in the parent process.
5158pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
5159    let root = std::fs::canonicalize(cwd)
5160        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5161    let mut files = BTreeMap::new();
5162    let mut visited = BTreeSet::new();
5163    collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
5164    Ok(AttributeState { files })
5165}
5166
5167fn collect_attribute_files(
5168    root: &Path,
5169    repository: &Path,
5170    prefix: &Path,
5171    visited: &mut BTreeSet<PathBuf>,
5172    files: &mut BTreeMap<PathBuf, [u8; 32]>,
5173) -> Result<()> {
5174    let canonical = std::fs::canonicalize(repository)
5175        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5176    if !visited.insert(canonical) {
5177        bail!("submodule recursion revisited {}", repository.display());
5178    }
5179    let entries = index_entries(repository)?;
5180    let mut paths: BTreeSet<PathBuf> = entries
5181        .iter()
5182        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5183        .map(|entry| entry.path.clone())
5184        .collect();
5185    // Ignored on purpose. A `.gitattributes` under build output can only select
5186    // filters for paths that are themselves ignored, and no ignored path is
5187    // ever staged or compared as tracked content, so it cannot reach a managed
5188    // commit. Capturing it meant `cargo package` or `npm ci` writing one into
5189    // its own output ended a read-only call outright.
5190    let untracked = run_git_bytes(
5191        repository,
5192        &[
5193            "ls-files",
5194            "--others",
5195            "--exclude-standard",
5196            "-z",
5197            "--",
5198            ".gitattributes",
5199            ":(glob)**/.gitattributes",
5200        ],
5201    )?;
5202    if !untracked.is_empty() && !untracked.ends_with(&[0]) {
5203        bail!(
5204            "git returned an unterminated attribute-file listing for {}",
5205            repository.display()
5206        );
5207    }
5208    for raw in untracked
5209        .split(|byte| *byte == 0)
5210        .filter(|record| !record.is_empty())
5211    {
5212        paths.insert(safe_git_path(raw, "attribute")?);
5213    }
5214    for path in paths {
5215        let from_root = prefix.join(&path);
5216        let state = attribute_file_fingerprint(&root.join(&from_root))?;
5217        files.insert(from_root, state);
5218    }
5219    for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
5220        let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
5221            continue;
5222        };
5223        collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
5224    }
5225    Ok(())
5226}
5227
5228/// Leave a visible, untracked reason ordinary cleanup can detect on a later
5229/// run even when Git status normalizes the original working-file change away.
5230pub(crate) fn uncertain_worktree_change(
5231    cwd: &Path,
5232    message: impl Into<String>,
5233) -> crate::error::SparError {
5234    let message = message.into();
5235    let marker = write_recovery_marker(cwd, &message);
5236    let note = match marker {
5237        Ok(path) => format!(" Recovery marker: {}.", path.display()),
5238        Err(e) => format!(
5239            " A recovery marker could not be written: {}.",
5240            e.last_line()
5241        ),
5242    };
5243    crate::error::SparError::uncertain_write(format!("{message}{note}"))
5244}
5245
5246fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
5247    use std::sync::atomic::{AtomicU32, Ordering};
5248    static NEXT: AtomicU32 = AtomicU32::new(0);
5249    for _ in 0..1000 {
5250        let serial = NEXT.fetch_add(1, Ordering::Relaxed);
5251        let path = cwd.join(format!(
5252            "{RECOVERY_MARKER_PREFIX}{}-{serial}",
5253            std::process::id()
5254        ));
5255        let mut options = OpenOptions::new();
5256        options.write(true).create_new(true);
5257        #[cfg(unix)]
5258        {
5259            use std::os::unix::fs::OpenOptionsExt;
5260            options.mode(0o600);
5261        }
5262        match options.open(&path) {
5263            Ok(mut file) => {
5264                file.write_all(detail.as_bytes())
5265                    .and_then(|_| file.write_all(b"\n"))
5266                    .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5267                return Ok(path);
5268            }
5269            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
5270            Err(e) => {
5271                return Err(spar_err!(
5272                    "could not create a recovery marker in {}: {e}",
5273                    cwd.display()
5274                ))
5275            }
5276        }
5277    }
5278    bail!(
5279        "could not choose a free recovery marker name in {}",
5280        cwd.display()
5281    )
5282}
5283
5284/// Build a Git command that cannot launch automatic repository maintenance.
5285///
5286/// A fetch may otherwise prune missing linked worktree registrations. SPAR
5287/// must only remove registrations it has proven it owns.
5288fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
5289    let mut argv = vec![
5290        "git".to_string(),
5291        "-c".to_string(),
5292        "maintenance.auto=false".to_string(),
5293        "-c".to_string(),
5294        "gc.auto=0".to_string(),
5295    ];
5296    argv.extend(args.iter().map(|arg| (*arg).to_string()));
5297    argv
5298}
5299
5300fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
5301    let mut argv = git_without_maintenance_argv(&[]);
5302    argv.extend([
5303        "-c".to_string(),
5304        "core.fsmonitor=".to_string(),
5305        "-c".to_string(),
5306        "commit.gpgsign=false".to_string(),
5307        "-c".to_string(),
5308        "core.hooksPath=/dev/null".to_string(),
5309    ]);
5310    argv.extend(args.iter().map(|arg| (*arg).to_string()));
5311    argv
5312}
5313
5314/// Snapshot every untracked file, including ignored files, without changing
5315/// path bytes.
5316///
5317/// Without an exclude option, Git lists both ordinary and ignored untracked
5318/// entries. Metadata fingerprints make overwriting an existing path observable
5319/// without hashing a potentially multi-gigabyte build tree on every call.
5320pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
5321    let root = std::fs::canonicalize(cwd)
5322        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5323    let mut files = BTreeMap::new();
5324    let mut ignored = BTreeSet::new();
5325    let mut visited = BTreeSet::new();
5326    collect_untracked_files(
5327        &root,
5328        &root,
5329        Path::new(""),
5330        &mut visited,
5331        &mut files,
5332        &mut ignored,
5333    )?;
5334    Ok(IgnoredState { files, ignored })
5335}
5336
5337fn collect_untracked_files(
5338    root: &Path,
5339    repository: &Path,
5340    prefix: &Path,
5341    visited: &mut BTreeSet<PathBuf>,
5342    files: &mut BTreeMap<PathBuf, UntrackedFile>,
5343    ignored: &mut BTreeSet<PathBuf>,
5344) -> Result<()> {
5345    let canonical = std::fs::canonicalize(repository)
5346        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5347    if !visited.insert(canonical.clone()) {
5348        bail!("submodule recursion revisited {}", canonical.display());
5349    }
5350    let listed = run_git_bytes(repository, &["ls-files", "--others", "-z"])?;
5351    if !listed.is_empty() && !listed.ends_with(&[0]) {
5352        bail!(
5353            "git returned an unterminated untracked-file list for {}",
5354            repository.display()
5355        );
5356    }
5357
5358    for raw in listed
5359        .split(|byte| *byte == 0)
5360        .filter(|raw| !raw.is_empty())
5361    {
5362        let (relative, nested) = untracked_record(raw, "untracked")?;
5363        let from_root = prefix.join(&relative);
5364        if is_spar_own_path(&from_root) {
5365            continue;
5366        }
5367        let absolute = root.join(&from_root);
5368        let fingerprint = if nested {
5369            nested_repository_fingerprint(&absolute)?
5370        } else {
5371            ignored_file_fingerprint(&absolute)?
5372        };
5373        if files.insert(from_root.clone(), fingerprint).is_some() {
5374            bail!(
5375                "git returned the untracked path more than once: {:?}",
5376                from_root
5377            );
5378        }
5379    }
5380
5381    let ignored_listed = run_git_bytes(
5382        repository,
5383        &[
5384            "ls-files",
5385            "--others",
5386            "--ignored",
5387            "--exclude-standard",
5388            "-z",
5389        ],
5390    )?;
5391    if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
5392        bail!(
5393            "git returned an unterminated ignored-file list for {}",
5394            repository.display()
5395        );
5396    }
5397    // Two `ls-files` runs cannot be made to agree about a tree that is being
5398    // built in. A path named only by the second one was created between them,
5399    // which is a fact about the moment, not a corruption: record it and let the
5400    // comparison decide, rather than ending a run that had nothing wrong with
5401    // it.
5402    for raw in ignored_listed
5403        .split(|byte| *byte == 0)
5404        .filter(|raw| !raw.is_empty())
5405    {
5406        let (relative, nested) = untracked_record(raw, "ignored")?;
5407        let from_root = prefix.join(relative);
5408        if is_spar_own_path(&from_root) {
5409            continue;
5410        }
5411        if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
5412            let absolute = root.join(&from_root);
5413            let fingerprint = if nested {
5414                nested_repository_fingerprint(&absolute)?
5415            } else {
5416                ignored_file_fingerprint(&absolute)?
5417            };
5418            slot.insert(fingerprint);
5419        }
5420        if !ignored.insert(from_root.clone()) {
5421            bail!(
5422                "git returned the ignored path more than once: {:?}",
5423                from_root
5424            );
5425        }
5426    }
5427
5428    for link in gitlinks(repository)? {
5429        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
5430            continue;
5431        };
5432        collect_untracked_files(
5433            root,
5434            &submodule,
5435            &prefix.join(&link.path),
5436            visited,
5437            files,
5438            ignored,
5439        )?;
5440    }
5441    Ok(())
5442}
5443
5444fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
5445    let argv = git_without_automation_argv(args);
5446    proc::run_bytes(
5447        &argv,
5448        &ExecOpts::new()
5449            .cwd(cwd)
5450            .timeout_secs(30)
5451            .stop_descendants(true),
5452    )
5453}
5454
5455fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
5456    let argv = git_without_automation_argv(args);
5457    proc::run(
5458        &argv,
5459        &ExecOpts::new()
5460            .cwd(cwd)
5461            .timeout_secs(30)
5462            .stop_descendants(true),
5463    )
5464}
5465
5466fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
5467    let path = path.to_str().ok_or_else(|| {
5468        spar_err!(
5469            "cannot verify filtered content for a non-UTF-8 path in {}",
5470            cwd.display()
5471        )
5472    })?;
5473    let path_arg = format!("--path={path}");
5474    let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
5475    Ok(Sha256::digest(bytes).into())
5476}
5477
5478fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
5479    let relative = path_from_git_bytes(raw)?;
5480    if relative.is_absolute()
5481        || relative.components().any(|component| {
5482            matches!(
5483                component,
5484                std::path::Component::ParentDir
5485                    | std::path::Component::RootDir
5486                    | std::path::Component::Prefix(_)
5487            )
5488        })
5489    {
5490        bail!("git returned an unsafe {kind} path: {:?}", relative);
5491    }
5492    Ok(relative)
5493}
5494
5495/// Split one `ls-files --others` record into its path and whether Git reported
5496/// a nested repository rather than a single file.
5497///
5498/// Git never lists the contents of a repository inside the working tree, so a
5499/// checkout parked there, such as another of SPAR's own worktrees, arrives as
5500/// one record for the directory itself ending in a separator. Git writes that
5501/// separator on every platform. Trimming it keeps the recorded path equal to
5502/// the same path seen any other way.
5503fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
5504    let nested = raw.last() == Some(&b'/');
5505    let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
5506    if trimmed.is_empty() {
5507        bail!("git returned an empty {kind} path");
5508    }
5509    Ok((safe_git_path(trimmed, kind)?, nested))
5510}
5511
5512fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
5513    let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
5514    if !listed.is_empty() && !listed.ends_with(&[0]) {
5515        bail!(
5516            "git returned an unterminated index listing for {}",
5517            cwd.display()
5518        );
5519    }
5520    let mut entries = Vec::new();
5521    for record in listed
5522        .split(|byte| *byte == 0)
5523        .filter(|record| !record.is_empty())
5524    {
5525        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5526            bail!(
5527                "git returned a malformed index record for {}",
5528                cwd.display()
5529            );
5530        };
5531        let header = &record[..tab];
5532        let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
5533        if fields.len() != 3 {
5534            bail!(
5535                "git returned a malformed index header for {}",
5536                cwd.display()
5537            );
5538        }
5539        if fields[2] != b"0" {
5540            continue;
5541        }
5542        let mode = std::str::from_utf8(fields[0])
5543            .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
5544            .to_string();
5545        let oid = std::str::from_utf8(fields[1])
5546            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5547            .to_string();
5548        entries.push(IndexEntry {
5549            path: safe_git_path(&record[tab + 1..], "index")?,
5550            mode,
5551            oid,
5552        });
5553    }
5554    Ok(entries)
5555}
5556
5557fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
5558    let untracked = run_git_bytes(
5559        cwd,
5560        &[
5561            "ls-files",
5562            "--others",
5563            "-z",
5564            "--",
5565            ".gitattributes",
5566            ":(glob)**/.gitattributes",
5567        ],
5568    )?;
5569    if !untracked.is_empty() {
5570        return Ok(true);
5571    }
5572
5573    let index = index_entries(cwd)?
5574        .into_iter()
5575        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5576        .map(|entry| (entry.path, (entry.mode, entry.oid)))
5577        .collect::<BTreeMap<_, _>>();
5578    let head = tree_entries(cwd, "HEAD")?
5579        .into_iter()
5580        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5581        .map(|entry| (entry.path, (entry.mode, entry.oid)))
5582        .collect::<BTreeMap<_, _>>();
5583    if index != head {
5584        return Ok(true);
5585    }
5586
5587    let effective = check_attributes(cwd, index.keys().cloned())?;
5588    let config = CheckoutConfig::read(cwd)?;
5589    for (path, (_mode, oid)) in index {
5590        let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
5591            return Ok(true);
5592        };
5593        let attributes = effective
5594            .get(&path)
5595            .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
5596        if allows_expected_crlf(&config, attributes)? {
5597            if worktree.mode == "120000" {
5598                return Ok(true);
5599            }
5600            let (normalized, every_lf_was_crlf) =
5601                normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
5602            if !every_lf_was_crlf || normalized != oid {
5603                return Ok(true);
5604            }
5605        } else if worktree.raw_oid != oid {
5606            return Ok(true);
5607        }
5608    }
5609    Ok(false)
5610}
5611
5612fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
5613    Ok(index_entries(cwd)?
5614        .into_iter()
5615        .filter(|entry| entry.mode == "160000")
5616        .map(|entry| Gitlink {
5617            path: entry.path,
5618            oid: entry.oid,
5619        })
5620        .collect())
5621}
5622
5623fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
5624    let mut tracked = BTreeMap::new();
5625    for entry in index_entries(cwd)? {
5626        if entry.mode == "160000" {
5627            continue;
5628        }
5629        let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
5630        tracked.insert(
5631            entry.path,
5632            TrackedEntry {
5633                index_mode: entry.mode,
5634                index_oid: entry.oid,
5635                worktree,
5636            },
5637        );
5638    }
5639    Ok(tracked)
5640}
5641
5642fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
5643    let metadata = match std::fs::symlink_metadata(path) {
5644        Ok(metadata) => metadata,
5645        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
5646        Err(e) => {
5647            return Err(spar_err!(
5648                "could not inspect tracked file {}: {e}",
5649                path.display()
5650            ))
5651        }
5652    };
5653    let mut fingerprint = Sha256::new();
5654    if metadata.file_type().is_symlink() {
5655        let target = std::fs::read_link(path)
5656            .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
5657        let bytes = os_str_bytes(target.as_os_str())?;
5658        fingerprint.update(b"symlink\0");
5659        fingerprint.update(&bytes);
5660        let content = Sha256::digest(&bytes).into();
5661        return Ok(Some(WorktreeFile {
5662            mode: "120000".to_string(),
5663            #[cfg(unix)]
5664            permissions: 0,
5665            raw_oid: git_blob_oid(oid_len, &bytes)?,
5666            fingerprint: fingerprint.finalize().into(),
5667            content,
5668        }));
5669    }
5670    if !metadata.is_file() {
5671        bail!("tracked path {} is not a file or symlink", path.display());
5672    }
5673
5674    let mut options = OpenOptions::new();
5675    options.read(true);
5676    #[cfg(unix)]
5677    {
5678        use std::os::unix::fs::OpenOptionsExt;
5679        options.custom_flags(libc::O_NOFOLLOW);
5680    }
5681    let mut file = options
5682        .open(path)
5683        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5684    let before = file
5685        .metadata()
5686        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5687    let mode = tracked_file_mode(&before);
5688    #[cfg(unix)]
5689    let permissions = {
5690        use std::os::unix::fs::MetadataExt;
5691        before.mode() & 0o7777
5692    };
5693    fingerprint.update(b"file\0");
5694    fingerprint.update(mode.as_bytes());
5695    #[cfg(unix)]
5696    fingerprint.update(permissions.to_le_bytes());
5697    fingerprint.update(before.len().to_le_bytes());
5698    let mut content = Sha256::new();
5699    let header = format!("blob {}\0", before.len());
5700    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5701    let mut buf = [0u8; 64 * 1024];
5702    loop {
5703        let read = file
5704            .read(&mut buf)
5705            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5706        if read == 0 {
5707            break;
5708        }
5709        fingerprint.update(&buf[..read]);
5710        content.update(&buf[..read]);
5711        object.update(&buf[..read]);
5712    }
5713    let after = file
5714        .metadata()
5715        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5716    if before.len() != after.len()
5717        || before.modified().ok() != after.modified().ok()
5718        || before.permissions() != after.permissions()
5719    {
5720        bail!(
5721            "tracked file {} changed while it was being inspected",
5722            path.display()
5723        );
5724    }
5725    let current = std::fs::symlink_metadata(path)
5726        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5727    if !same_file(&after, &current) {
5728        bail!(
5729            "tracked file {} was replaced while it was being inspected",
5730            path.display()
5731        );
5732    }
5733    Ok(Some(WorktreeFile {
5734        mode,
5735        #[cfg(unix)]
5736        permissions,
5737        raw_oid: object.finish(),
5738        fingerprint: fingerprint.finalize().into(),
5739        content: content.finalize().into(),
5740    }))
5741}
5742
5743fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
5744    let metadata = std::fs::symlink_metadata(path)
5745        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5746    let mut digest = Sha256::new();
5747    if metadata.file_type().is_symlink() {
5748        digest.update(b"symlink\0");
5749        let target = std::fs::read_link(path)
5750            .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
5751        digest.update(os_str_bytes(target.as_os_str())?);
5752        return Ok(digest.finalize().into());
5753    }
5754    if !metadata.is_file() {
5755        bail!("attribute path {} is not a file or symlink", path.display());
5756    }
5757    let mut options = OpenOptions::new();
5758    options.read(true);
5759    #[cfg(unix)]
5760    {
5761        use std::os::unix::fs::OpenOptionsExt;
5762        options.custom_flags(libc::O_NOFOLLOW);
5763    }
5764    let mut file = options
5765        .open(path)
5766        .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5767    let before = file
5768        .metadata()
5769        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5770    digest.update(b"file\0");
5771    let mut buf = [0u8; 64 * 1024];
5772    loop {
5773        let read = file
5774            .read(&mut buf)
5775            .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5776        if read == 0 {
5777            break;
5778        }
5779        digest.update(&buf[..read]);
5780    }
5781    let after = file
5782        .metadata()
5783        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5784    let current = std::fs::symlink_metadata(path)
5785        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5786    if before.len() != after.len()
5787        || before.modified().ok() != after.modified().ok()
5788        || !same_file(&after, &current)
5789    {
5790        bail!(
5791            "attribute file {} changed while it was being inspected",
5792            path.display()
5793        );
5794    }
5795    Ok(digest.finalize().into())
5796}
5797
5798enum ObjectHasher {
5799    Sha1(Sha1),
5800    Sha256(Sha256),
5801}
5802
5803impl ObjectHasher {
5804    fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
5805        let mut hasher = match oid_len {
5806            40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
5807            64 => Self::Sha256(Sha256::new()),
5808            _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
5809        };
5810        hasher.update(header);
5811        Ok(hasher)
5812    }
5813
5814    fn update(&mut self, bytes: &[u8]) {
5815        match self {
5816            Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
5817            Self::Sha256(hasher) => hasher.update(bytes),
5818        }
5819    }
5820
5821    fn finish(self) -> String {
5822        let bytes = match self {
5823            Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
5824            Self::Sha256(hasher) => hasher.finalize().to_vec(),
5825        };
5826        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
5827    }
5828}
5829
5830fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
5831    let header = format!("blob {}\0", bytes.len());
5832    let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
5833    hasher.update(bytes);
5834    Ok(hasher.finish())
5835}
5836
5837fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
5838    let mut first = open_regular_file(path)?;
5839    let first_before = first
5840        .metadata()
5841        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5842    let mut raw_len = 0u64;
5843    let mut crlf_pairs = 0u64;
5844    let mut previous_was_cr = false;
5845    let mut every_lf_was_crlf = true;
5846    let mut buf = [0u8; 64 * 1024];
5847    loop {
5848        let read = first
5849            .read(&mut buf)
5850            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5851        if read == 0 {
5852            break;
5853        }
5854        raw_len = raw_len
5855            .checked_add(read as u64)
5856            .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
5857        for byte in &buf[..read] {
5858            if *byte == b'\n' {
5859                if previous_was_cr {
5860                    crlf_pairs += 1;
5861                } else {
5862                    every_lf_was_crlf = false;
5863                }
5864            }
5865            previous_was_cr = *byte == b'\r';
5866        }
5867    }
5868    let first_after = first
5869        .metadata()
5870        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5871    let current = std::fs::symlink_metadata(path)
5872        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5873    if raw_len != first_before.len()
5874        || !stable_file_metadata(&first_before, &first_after)
5875        || !stable_file_metadata(&first_after, &current)
5876    {
5877        bail!(
5878            "tracked file {} changed while line endings were inspected",
5879            path.display()
5880        );
5881    }
5882
5883    let normalized_len = raw_len
5884        .checked_sub(crlf_pairs)
5885        .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
5886    let header = format!("blob {normalized_len}\0");
5887    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5888    let mut second = open_regular_file(path)?;
5889    let second_before = second
5890        .metadata()
5891        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5892    if !stable_file_metadata(&first_after, &second_before) {
5893        bail!(
5894            "tracked file {} changed between line-ending checks",
5895            path.display()
5896        );
5897    }
5898    let mut pending_cr = false;
5899    loop {
5900        let read = second
5901            .read(&mut buf)
5902            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5903        if read == 0 {
5904            break;
5905        }
5906        for byte in &buf[..read] {
5907            if pending_cr {
5908                if *byte == b'\n' {
5909                    object.update(b"\n");
5910                    pending_cr = false;
5911                    continue;
5912                }
5913                object.update(b"\r");
5914                pending_cr = false;
5915            }
5916            if *byte == b'\r' {
5917                pending_cr = true;
5918            } else {
5919                object.update(std::slice::from_ref(byte));
5920            }
5921        }
5922    }
5923    if pending_cr {
5924        object.update(b"\r");
5925    }
5926    let second_after = second
5927        .metadata()
5928        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5929    let current = std::fs::symlink_metadata(path)
5930        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5931    if !stable_file_metadata(&second_before, &second_after)
5932        || !stable_file_metadata(&second_after, &current)
5933    {
5934        bail!(
5935            "tracked file {} changed while line endings were hashed",
5936            path.display()
5937        );
5938    }
5939    Ok((object.finish(), every_lf_was_crlf))
5940}
5941
5942fn open_regular_file(path: &Path) -> Result<std::fs::File> {
5943    let mut options = OpenOptions::new();
5944    options.read(true);
5945    #[cfg(unix)]
5946    {
5947        use std::os::unix::fs::OpenOptionsExt;
5948        options.custom_flags(libc::O_NOFOLLOW);
5949    }
5950    let file = options
5951        .open(path)
5952        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5953    let metadata = file
5954        .metadata()
5955        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5956    if !metadata.is_file() {
5957        bail!("tracked path {} is not a regular file", path.display());
5958    }
5959    Ok(file)
5960}
5961
5962fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
5963    if !same_file(left, right)
5964        || left.len() != right.len()
5965        || left.modified().ok() != right.modified().ok()
5966        || left.permissions() != right.permissions()
5967    {
5968        return false;
5969    }
5970    #[cfg(unix)]
5971    {
5972        use std::os::unix::fs::MetadataExt;
5973        left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
5974    }
5975    #[cfg(not(unix))]
5976    {
5977        left.created().ok() == right.created().ok()
5978    }
5979}
5980
5981/// How many paths one `check-attr` call is asked about.
5982///
5983/// `check-attr` answers with three NUL fields per attribute per path, so the
5984/// output is several times the input. A whole monorepo in one call is megabytes
5985/// in both directions, and the 30 second timeout below is a timeout on the
5986/// batch. Batching keeps each call small enough to be honest about.
5987const ATTRIBUTE_BATCH: usize = 1000;
5988
5989fn check_attributes(
5990    cwd: &Path,
5991    paths: impl IntoIterator<Item = PathBuf>,
5992) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5993    let paths = paths.into_iter().collect::<BTreeSet<_>>();
5994    if paths.is_empty() {
5995        return Ok(BTreeMap::new());
5996    }
5997    let ordered = paths.into_iter().collect::<Vec<_>>();
5998    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5999    for batch in ordered.chunks(ATTRIBUTE_BATCH) {
6000        values.extend(check_attribute_batch(cwd, batch)?);
6001    }
6002    Ok(values)
6003}
6004
6005fn check_attribute_batch(
6006    cwd: &Path,
6007    batch: &[PathBuf],
6008) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
6009    const NAMES: [&str; 6] = [
6010        "filter",
6011        "working-tree-encoding",
6012        "ident",
6013        "text",
6014        "eol",
6015        "crlf",
6016    ];
6017    let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
6018    let mut input = String::new();
6019    for path in &paths {
6020        let path = path.to_str().ok_or_else(|| {
6021            spar_err!(
6022                "cannot inspect attributes for a non-UTF-8 path in {}",
6023                cwd.display()
6024            )
6025        })?;
6026        input.push_str(path);
6027        input.push('\0');
6028    }
6029    let argv = git_without_automation_argv(&[
6030        "check-attr",
6031        "-z",
6032        "--cached",
6033        "--stdin",
6034        "filter",
6035        "working-tree-encoding",
6036        "ident",
6037        "text",
6038        "eol",
6039        "crlf",
6040    ]);
6041    let output = proc::run_bytes(
6042        &argv,
6043        &ExecOpts::new()
6044            .cwd(cwd)
6045            .timeout_secs(30)
6046            .stdin(input)
6047            .stop_descendants(true),
6048    )?;
6049    if !output.is_empty() && !output.ends_with(&[0]) {
6050        bail!(
6051            "git returned an unterminated attribute result for {}",
6052            cwd.display()
6053        );
6054    }
6055    let fields = output
6056        .split(|byte| *byte == 0)
6057        .filter(|field| !field.is_empty())
6058        .collect::<Vec<_>>();
6059    if fields.len() != paths.len() * NAMES.len() * 3 {
6060        bail!(
6061            "git returned an unexpected attribute result for {}",
6062            cwd.display()
6063        );
6064    }
6065    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
6066    for record in fields.chunks_exact(3) {
6067        let path = safe_git_path(record[0], "attribute")?;
6068        if !paths.contains(&path) {
6069            bail!(
6070                "git returned attributes for the wrong path in {}",
6071                cwd.display()
6072            );
6073        }
6074        let name = std::str::from_utf8(record[1])
6075            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
6076        let value = std::str::from_utf8(record[2])
6077            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
6078        values
6079            .entry(path)
6080            .or_default()
6081            .insert(name.to_string(), value.to_string());
6082    }
6083    if paths.iter().any(|path| {
6084        values
6085            .get(path)
6086            .is_none_or(|attributes| attributes.len() != NAMES.len())
6087    }) {
6088        bail!(
6089            "git omitted an attribute result for a tracked path in {}",
6090            cwd.display()
6091        );
6092    }
6093    Ok(values)
6094}
6095
6096fn attribute_is_active(value: Option<&String>) -> bool {
6097    !matches!(
6098        value.map(String::as_str),
6099        None | Some("unspecified") | Some("unset")
6100    )
6101}
6102
6103fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
6104    attribute_is_active(values.get("filter"))
6105        || attribute_is_active(values.get("working-tree-encoding"))
6106}
6107
6108/// The checkout settings a per-file line-ending decision depends on.
6109///
6110/// These are constant for a worktree, but the checks that read them run once
6111/// per tracked file, and each read was its own `git config` process. A
6112/// thousand-file repository with no `.gitattributes` takes every one of those
6113/// branches, which is half a minute of process spawning per scan, and the sweep
6114/// before a run scans every finished worktree more than once. Read them here,
6115/// once, and hand them down.
6116struct CheckoutConfig {
6117    autocrlf: Option<String>,
6118    eol: Option<String>,
6119    symlinks: Option<bool>,
6120}
6121
6122impl CheckoutConfig {
6123    fn read(cwd: &Path) -> Result<Self> {
6124        Ok(Self {
6125            autocrlf: git_config_value(cwd, "core.autocrlf")?,
6126            eol: git_config_value(cwd, "core.eol")?,
6127            symlinks: git_config_bool(cwd, "core.symlinks")?,
6128        })
6129    }
6130
6131    fn autocrlf_is_true(&self) -> bool {
6132        self.autocrlf.as_ref().is_some_and(|value| {
6133            matches!(
6134                value.to_ascii_lowercase().as_str(),
6135                "true" | "yes" | "on" | "1"
6136            )
6137        })
6138    }
6139
6140    fn eol_is(&self, wanted: &str) -> bool {
6141        self.eol
6142            .as_ref()
6143            .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
6144    }
6145}
6146
6147fn path_has_ambiguous_transform(
6148    config: &CheckoutConfig,
6149    values: &BTreeMap<String, String>,
6150) -> Result<bool> {
6151    if path_has_external_transform(values)
6152        || attribute_is_active(values.get("ident"))
6153        || attribute_is_active(values.get("crlf"))
6154    {
6155        return Ok(true);
6156    }
6157    let text = values.get("text").map(String::as_str);
6158    let eol = values.get("eol").map(String::as_str);
6159    if text == Some("auto") {
6160        return Ok(true);
6161    }
6162    if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
6163        || !matches!(
6164            eol,
6165            Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
6166        )
6167    {
6168        return Ok(true);
6169    }
6170    if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
6171        return Ok(config.autocrlf_is_true());
6172    }
6173    Ok(false)
6174}
6175
6176fn allows_expected_crlf(
6177    config: &CheckoutConfig,
6178    values: &BTreeMap<String, String>,
6179) -> Result<bool> {
6180    if path_has_external_transform(values)
6181        || attribute_is_active(values.get("ident"))
6182        || attribute_is_active(values.get("crlf"))
6183    {
6184        return Ok(false);
6185    }
6186    let text = values.get("text").map(String::as_str);
6187    let eol = values.get("eol").map(String::as_str);
6188    if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
6189        return Ok(false);
6190    }
6191    if eol == Some("crlf") {
6192        return Ok(true);
6193    }
6194    if text != Some("set") {
6195        return Ok(false);
6196    }
6197    if let Some(autocrlf) = config.autocrlf.as_deref() {
6198        match autocrlf.to_ascii_lowercase().as_str() {
6199            "true" | "yes" | "on" | "1" => return Ok(true),
6200            "input" => return Ok(false),
6201            _ => {}
6202        }
6203    }
6204    if config.eol_is("crlf") {
6205        return Ok(true);
6206    }
6207    #[cfg(windows)]
6208    if config.eol.is_none() || config.eol_is("native") {
6209        return Ok(true);
6210    }
6211    Ok(false)
6212}
6213
6214fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
6215    let argv = git_without_automation_argv(&["config", "--get", key]);
6216    let output = proc::exec(
6217        &argv,
6218        &ExecOpts::new()
6219            .cwd(cwd)
6220            .timeout_secs(30)
6221            .check(false)
6222            .stop_descendants(true),
6223    )?;
6224    match output.code {
6225        0 => Ok(Some(output.stdout.trim().to_string())),
6226        1 => Ok(None),
6227        _ => bail!(
6228            "could not read Git configuration in {}: {}",
6229            cwd.display(),
6230            output.stderr.trim()
6231        ),
6232    }
6233}
6234
6235fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
6236    let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
6237    let output = proc::exec(
6238        &argv,
6239        &ExecOpts::new()
6240            .cwd(cwd)
6241            .timeout_secs(30)
6242            .check(false)
6243            .stop_descendants(true),
6244    )?;
6245    match output.code {
6246        0 if output.stdout.trim() == "true" => Ok(Some(true)),
6247        0 if output.stdout.trim() == "false" => Ok(Some(false)),
6248        0 => bail!(
6249            "git returned an invalid boolean for {key} in {}",
6250            cwd.display()
6251        ),
6252        1 => Ok(None),
6253        _ => bail!(
6254            "could not read Git configuration in {}: {}",
6255            cwd.display(),
6256            output.stderr.trim()
6257        ),
6258    }
6259}
6260
6261#[cfg(unix)]
6262fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
6263    use std::os::unix::fs::PermissionsExt;
6264    if metadata.permissions().mode() & 0o111 == 0 {
6265        "100644".to_string()
6266    } else {
6267        "100755".to_string()
6268    }
6269}
6270
6271#[cfg(not(unix))]
6272fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
6273    "100644".to_string()
6274}
6275
6276fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
6277    let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
6278    if !listed.is_empty() && !listed.ends_with(&[0]) {
6279        bail!(
6280            "git returned an unterminated tree listing for {}",
6281            cwd.display()
6282        );
6283    }
6284    let mut entries = Vec::new();
6285    for record in listed
6286        .split(|byte| *byte == 0)
6287        .filter(|record| !record.is_empty())
6288    {
6289        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6290            bail!("git returned a malformed tree record for {}", cwd.display());
6291        };
6292        let fields = record[..tab]
6293            .split(|byte| *byte == b' ')
6294            .collect::<Vec<_>>();
6295        if fields.len() != 3 {
6296            bail!("git returned a malformed tree header for {}", cwd.display());
6297        }
6298        let mode = std::str::from_utf8(fields[0])
6299            .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
6300            .to_string();
6301        let oid = std::str::from_utf8(fields[2])
6302            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6303            .to_string();
6304        entries.push(IndexEntry {
6305            path: safe_git_path(&record[tab + 1..], "tree")?,
6306            mode,
6307            oid,
6308        });
6309    }
6310    Ok(entries)
6311}
6312
6313fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
6314    Ok(tree_entries(cwd, "HEAD")?
6315        .into_iter()
6316        .filter(|entry| entry.mode == "160000")
6317        .map(|entry| (entry.path, entry.oid))
6318        .collect())
6319}
6320
6321fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
6322    let head = head_gitlinks(cwd)?;
6323    let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
6324        .into_iter()
6325        .map(|link| (link.path, link.oid))
6326        .collect();
6327    let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
6328    paths.extend(index.keys().cloned());
6329    Ok(paths
6330        .into_iter()
6331        .filter(|path| head.get(path) != index.get(path))
6332        .collect())
6333}
6334
6335fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
6336    let path = parent.join(relative);
6337    let metadata = match std::fs::symlink_metadata(&path) {
6338        Ok(metadata) => metadata,
6339        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6340        Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6341    };
6342    if !metadata.is_dir() {
6343        bail!("the gitlink at {} is not a directory", path.display());
6344    }
6345    let canonical = std::fs::canonicalize(&path)
6346        .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
6347    if canonical != path {
6348        bail!(
6349            "the gitlink at {} resolves through a symlink",
6350            path.display()
6351        );
6352    }
6353    if !path.join(".git").exists() {
6354        let empty = std::fs::read_dir(&path)
6355            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
6356            .next()
6357            .is_none();
6358        if empty {
6359            return Ok(None);
6360        }
6361        bail!(
6362            "the uninitialized gitlink at {} contains local files",
6363            path.display()
6364        );
6365    }
6366    let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
6367    if inside.trim() != "true" {
6368        bail!("the gitlink at {} is not a worktree", path.display());
6369    }
6370    let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
6371    let top = std::fs::canonicalize(top.trim()).map_err(|e| {
6372        spar_err!(
6373            "could not resolve the gitlink top level at {}: {e}",
6374            path.display()
6375        )
6376    })?;
6377    if top != canonical {
6378        bail!(
6379            "the gitlink at {} belongs to a different worktree",
6380            path.display()
6381        );
6382    }
6383    Ok(Some(canonical))
6384}
6385
6386/// A Git entry the outer repository cannot account for.
6387///
6388/// A checkout Git itself reports as a nested repository is expected. Its
6389/// directory is recorded by identity in the untracked snapshot and keeps the
6390/// worktree from being released, so a reviewer that clones a reproduction into
6391/// ignored build output neither loses that clone nor fails the run over it.
6392/// The scan does not descend into one, because what is under it belongs to
6393/// that repository.
6394///
6395/// A Git entry Git does not report is the one worth stopping for. Running
6396/// `git init` inside a directory that already holds tracked files leaves
6397/// nothing in any listing while making every file written under it invisible
6398/// to the outer worktree, so SPAR can no longer prove what is there.
6399fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
6400    let root = std::fs::canonicalize(cwd)
6401        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6402    let mut allowed = BTreeSet::from([root.join(".git")]);
6403    let mut checkouts = BTreeSet::new();
6404    let mut repositories = vec![root.clone()];
6405    let mut visited = BTreeSet::new();
6406    while let Some(repository) = repositories.pop() {
6407        let canonical = std::fs::canonicalize(&repository)
6408            .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6409        if !visited.insert(canonical.clone()) {
6410            bail!("submodule recursion revisited {}", canonical.display());
6411        }
6412        for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
6413            let (path, nested) = untracked_record(&raw, "untracked")?;
6414            if nested {
6415                checkouts.insert(canonical.join(path));
6416            }
6417        }
6418        for link in gitlinks(&canonical)? {
6419            let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
6420                continue;
6421            };
6422            allowed.insert(submodule.join(".git"));
6423            repositories.push(submodule);
6424        }
6425    }
6426
6427    // A build deletes its own directories while this walks them. A directory
6428    // that is gone holds no Git entry, so skipping it answers the question
6429    // being asked; erroring instead ended runs whose worktrees were fine.
6430    let scan_root = root.clone();
6431    let mut directories = vec![root];
6432    while let Some(directory) = directories.pop() {
6433        let entries = match std::fs::read_dir(&directory) {
6434            Ok(entries) => entries,
6435            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6436            Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6437        };
6438        for entry in entries {
6439            let entry = match entry {
6440                Ok(entry) => entry,
6441                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6442                Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6443            };
6444            let path = entry.path();
6445            if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
6446                continue;
6447            }
6448            if entry.file_name() == OsStr::new(".git") {
6449                if !allowed.contains(&path) {
6450                    return Ok(Some(path));
6451                }
6452                continue;
6453            }
6454            let kind = match entry.file_type() {
6455                Ok(kind) => kind,
6456                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6457                Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6458            };
6459            if kind.is_dir() && !checkouts.contains(&path) {
6460                directories.push(path);
6461            }
6462        }
6463    }
6464    Ok(None)
6465}
6466
6467pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
6468    if let Some(path) = unexpected_nested_git_entry(cwd)? {
6469        bail!(
6470            "the worktree contains a Git entry at {} that Git does not report as a nested \
6471             checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
6472             prove they are unchanged.",
6473            path.display()
6474        );
6475    }
6476    let root = std::fs::canonicalize(cwd)
6477        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6478    let mut repositories = BTreeMap::new();
6479    let mut visited = BTreeSet::new();
6480    collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
6481    Ok(GitState { repositories })
6482}
6483
6484fn collect_git_state(
6485    repository: &Path,
6486    prefix: &Path,
6487    visited: &mut BTreeSet<PathBuf>,
6488    repositories: &mut BTreeMap<PathBuf, RepositoryState>,
6489) -> Result<()> {
6490    let canonical = std::fs::canonicalize(repository)
6491        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6492    if !visited.insert(canonical.clone()) {
6493        bail!("submodule recursion revisited {}", canonical.display());
6494    }
6495    let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6496    let head = head.trim().to_string();
6497    if head.is_empty() {
6498        bail!("git returned an empty head for {}", repository.display());
6499    }
6500    let unsafe_index_flags = unsafe_index_flags(repository)?;
6501    let tracked = tracked_entries(repository)?;
6502    let gitlinks = gitlinks(repository)?;
6503    if repositories
6504        .insert(
6505            prefix.to_path_buf(),
6506            RepositoryState {
6507                head,
6508                unsafe_index_flags,
6509                tracked,
6510                gitlinks: gitlinks
6511                    .iter()
6512                    .map(|link| (link.path.clone(), link.oid.clone()))
6513                    .collect(),
6514            },
6515        )
6516        .is_some()
6517    {
6518        bail!("Git state contains duplicate repository path {:?}", prefix);
6519    }
6520
6521    for link in gitlinks {
6522        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6523            continue;
6524        };
6525        collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
6526    }
6527    Ok(())
6528}
6529
6530fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
6531    let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
6532    if !listed.is_empty() && !listed.ends_with(&[0]) {
6533        bail!(
6534            "git returned an unterminated index-flag listing for {}",
6535            cwd.display()
6536        );
6537    }
6538    let mut unsafe_records = Vec::new();
6539    for record in listed
6540        .split(|byte| *byte == 0)
6541        .filter(|record| !record.is_empty())
6542    {
6543        if record.len() < 3 || record[1] != b' ' {
6544            bail!(
6545                "git returned a malformed index-flag record for {}",
6546                cwd.display()
6547            );
6548        }
6549        if record[0] != b'H' {
6550            unsafe_records.extend_from_slice(record);
6551            unsafe_records.push(0);
6552        }
6553    }
6554    Ok(unsafe_records)
6555}
6556
6557pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
6558    safe_git_state(cwd).map(|_| ())
6559}
6560
6561pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
6562    let state = git_state(cwd)?;
6563    if let Some((path, _repository)) = state
6564        .repositories
6565        .iter()
6566        .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
6567    {
6568        let label = if path.as_os_str().is_empty() {
6569            cwd.to_path_buf()
6570        } else {
6571            cwd.join(path)
6572        };
6573        bail!(
6574            "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
6575             SPAR cannot prove the working files are unchanged, so it was kept.",
6576            label.display()
6577        );
6578    }
6579    Ok(state)
6580}
6581
6582fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
6583    repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
6584}
6585
6586fn repository_has_recoverable_work_with(
6587    cwd: &Path,
6588    include_ignored: bool,
6589    disposable: Disposable,
6590) -> Result<bool> {
6591    if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
6592        return Ok(true);
6593    }
6594    let mut visited = BTreeSet::new();
6595    repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
6596}
6597
6598fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
6599    let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
6600    let git_dir = PathBuf::from(git_dir.trim());
6601    let git_dir = if git_dir.is_absolute() {
6602        git_dir
6603    } else {
6604        cwd.join(git_dir)
6605    };
6606    let git_dir = std::fs::canonicalize(&git_dir)
6607        .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
6608    match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
6609        Ok(_) => return Ok(true),
6610        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6611        Err(error) => {
6612            return Err(spar_err!(
6613                "could not inspect per-worktree configuration in {}: {error}",
6614                git_dir.display()
6615            ))
6616        }
6617    }
6618
6619    let orig_head = git_dir.join("ORIG_HEAD");
6620    match std::fs::symlink_metadata(&orig_head) {
6621        Ok(metadata) if metadata.is_file() => {
6622            let oid = std::fs::read_to_string(&orig_head)
6623                .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
6624            let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
6625                return Ok(true);
6626            };
6627            if !commit_has_shared_ref(cwd, &commit)? {
6628                return Ok(true);
6629            }
6630        }
6631        Ok(_) => return Ok(true),
6632        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6633        Err(error) => {
6634            return Err(spar_err!(
6635                "could not inspect {}: {error}",
6636                orig_head.display()
6637            ))
6638        }
6639    }
6640
6641    let edit_message = git_dir.join("COMMIT_EDITMSG");
6642    match std::fs::symlink_metadata(&edit_message) {
6643        Ok(metadata) if metadata.is_file() => {
6644            let draft = std::fs::read(&edit_message)
6645                .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
6646            if draft != head_commit_message(cwd)? {
6647                return Ok(true);
6648            }
6649        }
6650        Ok(_) => return Ok(true),
6651        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6652        Err(error) => {
6653            return Err(spar_err!(
6654                "could not inspect {}: {error}",
6655                edit_message.display()
6656            ))
6657        }
6658    }
6659
6660    if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
6661        return Ok(true);
6662    }
6663
6664    let local_refs = run_git_bytes(
6665        cwd,
6666        &[
6667            "for-each-ref",
6668            "--format=%(refname)",
6669            "refs/worktree",
6670            "refs/bisect",
6671            "refs/rewritten",
6672        ],
6673    )?;
6674    if !local_refs.is_empty() {
6675        return Ok(true);
6676    }
6677
6678    for entry in std::fs::read_dir(&git_dir)
6679        .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
6680    {
6681        let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
6682        let known = matches!(
6683            entry.file_name().to_str(),
6684            Some(
6685                "HEAD"
6686                    | "ORIG_HEAD"
6687                    | "COMMIT_EDITMSG"
6688                    | "commondir"
6689                    | "gitdir"
6690                    | "index"
6691                    | "logs"
6692                    | "refs"
6693            )
6694        );
6695        if !known {
6696            return Ok(true);
6697        }
6698    }
6699
6700    let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6701    if !commit_has_shared_ref(cwd, head.trim())? {
6702        return Ok(true);
6703    }
6704    Ok(false)
6705}
6706
6707fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
6708    let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
6709    let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
6710        bail!(
6711            "git returned a commit without a message separator in {}",
6712            cwd.display()
6713        );
6714    };
6715    Ok(commit[split + 2..].to_vec())
6716}
6717
6718fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
6719    let metadata = match std::fs::symlink_metadata(logs) {
6720        Ok(metadata) => metadata,
6721        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6722        Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
6723    };
6724    if !metadata.is_dir() {
6725        return Ok(true);
6726    }
6727    let mut files = Vec::new();
6728    let mut directories = vec![logs.to_path_buf()];
6729    while let Some(directory) = directories.pop() {
6730        for entry in std::fs::read_dir(&directory)
6731            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
6732        {
6733            let entry =
6734                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
6735            let path = entry.path();
6736            let kind = entry
6737                .file_type()
6738                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
6739            if kind.is_dir() {
6740                directories.push(path);
6741            } else if kind.is_file() {
6742                files.push(path);
6743            } else {
6744                return Ok(true);
6745            }
6746        }
6747    }
6748
6749    let mut commits = BTreeSet::new();
6750    for path in files {
6751        if !collect_reflog_commits(cwd, &path, &mut commits)? {
6752            return Ok(true);
6753        }
6754    }
6755    for commit in commits {
6756        if !commit_has_shared_ref(cwd, &commit)? {
6757            return Ok(true);
6758        }
6759    }
6760    Ok(false)
6761}
6762
6763/// Every commit named by one ref's common reflog must survive deletion of that
6764/// ref. Ancestors of a durable current tip survive with the tip; divergent
6765/// entries need another shared ref of their own.
6766fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
6767    let common = common_git_dir(cwd)?;
6768    let reflog = common.join("logs").join(refname);
6769    let metadata = match std::fs::symlink_metadata(&reflog) {
6770        Ok(metadata) => metadata,
6771        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
6772        Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
6773    };
6774    if !metadata.is_file() {
6775        return Ok(false);
6776    }
6777    let mut commits = BTreeSet::new();
6778    if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
6779        return Ok(false);
6780    }
6781    for commit in commits {
6782        if is_ancestor(cwd, &commit, durable_tip)?
6783            || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
6784        {
6785            continue;
6786        }
6787        return Ok(false);
6788    }
6789    Ok(true)
6790}
6791
6792fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
6793    let file = std::fs::File::open(path)
6794        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6795    for line in std::io::BufReader::new(file).lines() {
6796        let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6797        let mut fields = line.splitn(3, ' ');
6798        let Some(old) = fields.next() else {
6799            return Ok(false);
6800        };
6801        let Some(new) = fields.next() else {
6802            return Ok(false);
6803        };
6804        if fields.next().is_none() {
6805            return Ok(false);
6806        }
6807        for oid in [old, new] {
6808            if oid.bytes().all(|byte| byte == b'0') {
6809                continue;
6810            }
6811            let Some(commit) = resolve_optional_commit(cwd, oid)? else {
6812                return Ok(false);
6813            };
6814            commits.insert(commit);
6815        }
6816    }
6817    Ok(true)
6818}
6819
6820fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
6821    let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
6822    let path = PathBuf::from(raw.trim());
6823    let path = if path.is_absolute() {
6824        path
6825    } else {
6826        cwd.join(path)
6827    };
6828    std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
6829}
6830
6831fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
6832    let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
6833    let output = proc::exec(
6834        &argv,
6835        &ExecOpts::new()
6836            .cwd(cwd)
6837            .timeout_secs(30)
6838            .check(false)
6839            .stop_descendants(true),
6840    )?;
6841    match output.code {
6842        0 => Ok(true),
6843        1 => Ok(false),
6844        _ => bail!("{}", proc::failure_message(&argv, &output)),
6845    }
6846}
6847
6848fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
6849    let commit = format!("{oid}^{{commit}}");
6850    let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
6851    let output = proc::exec(
6852        &argv,
6853        &ExecOpts::new()
6854            .cwd(cwd)
6855            .timeout_secs(30)
6856            .check(false)
6857            .stop_descendants(true),
6858    )?;
6859    if output.code != 0 {
6860        return Ok(None);
6861    }
6862    let oid = output.stdout.trim();
6863    if oid.is_empty() {
6864        return Ok(None);
6865    }
6866    Ok(Some(oid.to_string()))
6867}
6868
6869fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
6870    commit_has_shared_ref_except(cwd, oid, None)
6871}
6872
6873fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
6874    let contains = format!("--contains={oid}");
6875    let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
6876    Ok(shared.split(|byte| *byte == b'\n').any(|record| {
6877        !record.is_empty()
6878            && !record.starts_with(b"refs/worktree/")
6879            && !record.starts_with(b"refs/bisect/")
6880            && !record.starts_with(b"refs/rewritten/")
6881            && exclude.is_none_or(|excluded| record != excluded.as_bytes())
6882    }))
6883}
6884
6885/// Whether removing the worktree would take away an untracked file somebody
6886/// might want back.
6887///
6888/// Ordinary untracked files always count. So does an ignored file outside the
6889/// known build and cache directories, because an ignored path is only a path
6890/// Git was told not to track, which is where a local `.env` lives as readily as
6891/// compiler output.
6892///
6893/// Recognized build and cache output does not. A managed commit already leaves
6894/// it out rather than treating it as work, and the command that wrote it writes
6895/// it again. Counting it kept every worktree whose tests or build had run,
6896/// which is nearly all of them, so a merged pull request still left its
6897/// checkout behind. A repository nested in that output is somebody else's
6898/// history and counts whatever it sits under.
6899fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
6900    let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
6901    for raw in &ordinary {
6902        let (path, _) = untracked_record(raw, "untracked")?;
6903        if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
6904            continue;
6905        }
6906        return Ok(true);
6907    }
6908    let listed = untracked_listing(
6909        cwd,
6910        &[
6911            "ls-files",
6912            "--others",
6913            "--ignored",
6914            "--exclude-standard",
6915            "-z",
6916        ],
6917    )?;
6918    for raw in listed {
6919        let (path, nested) = untracked_record(&raw, "ignored")?;
6920        // A repository cloned into the output keeps the checkout whatever the
6921        // mode: its objects exist nowhere else, which is the one thing a
6922        // rebuild from origin cannot replace.
6923        if nested {
6924            return Ok(true);
6925        }
6926        if disposable == Disposable::BuildOutput && !is_generated_artifact(&path) {
6927            return Ok(true);
6928        }
6929    }
6930    Ok(false)
6931}
6932
6933fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
6934    let listed = run_git_bytes(cwd, args)?;
6935    if !listed.is_empty() && !listed.ends_with(&[0]) {
6936        bail!(
6937            "git returned an unterminated untracked-file list for {}",
6938            cwd.display()
6939        );
6940    }
6941    Ok(listed
6942        .split(|byte| *byte == 0)
6943        .filter(|raw| !raw.is_empty())
6944        .map(|raw| raw.to_vec())
6945        .collect())
6946}
6947
6948fn repository_has_recoverable_work_inner(
6949    cwd: &Path,
6950    include_ignored: bool,
6951    disposable: Disposable,
6952    visited: &mut BTreeSet<PathBuf>,
6953) -> Result<bool> {
6954    let canonical = std::fs::canonicalize(cwd)
6955        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6956    if !visited.insert(canonical.clone()) {
6957        bail!("submodule recursion revisited {}", canonical.display());
6958    }
6959    if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
6960        return Ok(true);
6961    }
6962    if !unsafe_index_flags(cwd)?.is_empty() {
6963        return Ok(true);
6964    }
6965    if attributes_may_be_modified(cwd)? {
6966        return Ok(true);
6967    }
6968    if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
6969        return Ok(true);
6970    }
6971    if include_ignored {
6972        let index = index_entries(cwd)?
6973            .into_iter()
6974            .map(|entry| (entry.path, (entry.mode, entry.oid)))
6975            .collect::<BTreeMap<_, _>>();
6976        let head = tree_entries(cwd, "HEAD")?
6977            .into_iter()
6978            .map(|entry| (entry.path, (entry.mode, entry.oid)))
6979            .collect::<BTreeMap<_, _>>();
6980        if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
6981            return Ok(true);
6982        }
6983        let tracked = tracked_entries(cwd)?;
6984        let effective = check_attributes(cwd, tracked.keys().cloned())?;
6985        let config = CheckoutConfig::read(cwd)?;
6986        for (path, entry) in tracked {
6987            let Some(worktree) = entry.worktree else {
6988                return Ok(true);
6989            };
6990            let attributes = effective.get(&path).ok_or_else(|| {
6991                spar_err!("git omitted attributes for {}", cwd.join(&path).display())
6992            })?;
6993            if path_has_ambiguous_transform(&config, attributes)? {
6994                return Ok(true);
6995            }
6996            let symlink_file = entry.index_mode == "120000"
6997                && worktree.mode == "100644"
6998                && worktree.raw_oid == entry.index_oid
6999                && config.symlinks == Some(false);
7000            if worktree.mode != entry.index_mode && !symlink_file {
7001                return Ok(true);
7002            }
7003            if entry.index_mode == "120000" {
7004                if worktree.raw_oid != entry.index_oid {
7005                    return Ok(true);
7006                }
7007                continue;
7008            }
7009            #[cfg(unix)]
7010            {
7011                let expected = if entry.index_mode == "100755" {
7012                    0o755
7013                } else {
7014                    0o644
7015                };
7016                if worktree.permissions != expected {
7017                    return Ok(true);
7018                }
7019            }
7020            if allows_expected_crlf(&config, attributes)? {
7021                let (normalized, every_lf_was_crlf) =
7022                    normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
7023                if !every_lf_was_crlf || normalized != entry.index_oid {
7024                    return Ok(true);
7025                }
7026            } else if worktree.raw_oid != entry.index_oid {
7027                return Ok(true);
7028            }
7029        }
7030    } else {
7031        let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
7032        if !run_git_bytes(cwd, &args)?.is_empty() {
7033            return Ok(true);
7034        }
7035    }
7036    for link in gitlinks(cwd)? {
7037        let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
7038            continue;
7039        };
7040        // Git stores a linked worktree's initialized submodule objects under
7041        // that worktree's administrative directory. Ordinary removal cannot
7042        // prove a local submodule commit exists anywhere else, even when both
7043        // working trees look clean.
7044        if include_ignored {
7045            return Ok(true);
7046        }
7047        let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
7048        if head.trim() != link.oid {
7049            return Ok(true);
7050        }
7051        if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
7052        {
7053            return Ok(true);
7054        }
7055    }
7056    Ok(false)
7057}
7058
7059pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
7060    repository_has_recoverable_work(cwd, false)
7061}
7062
7063fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
7064    let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
7065    Ok(!run_git_bytes(cwd, &args)?.is_empty())
7066}
7067
7068#[cfg(unix)]
7069fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
7070    use std::os::unix::ffi::OsStringExt;
7071    Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
7072}
7073
7074#[cfg(not(unix))]
7075fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
7076    String::from_utf8(raw.to_vec())
7077        .map(PathBuf::from)
7078        .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
7079}
7080
7081/// Fingerprint the directory of a nested repository without reading inside it.
7082///
7083/// The files under it are that repository's, not this one's. They are recorded
7084/// against its own baseline whenever SPAR works there, and a run of its own may
7085/// legitimately add or remove entries while this call is in flight, so the
7086/// volatile directory fields stay out of the fingerprint. Identity and type
7087/// remain, which is what makes deleting the checkout, or replacing it with a
7088/// file, observable from the outer worktree.
7089/// What a path Git listed looks like once it is no longer there.
7090///
7091/// A reviewer building the project deletes and rewrites its output constantly,
7092/// so a path can go between the listing that named it and the stat that
7093/// measures it. Treating that as an unreadable worktree ended runs that had
7094/// nothing wrong with them. The record stays in the snapshot rather than being
7095/// dropped, so a path that vanishes cannot pass for one that was never there.
7096fn vanished_untracked_file() -> UntrackedFile {
7097    UntrackedFile {
7098        kind: 0,
7099        len: 0,
7100        modified: None,
7101        created: None,
7102        readonly: false,
7103        symlink_target: None,
7104        #[cfg(unix)]
7105        device: 0,
7106        #[cfg(unix)]
7107        inode: 0,
7108        #[cfg(unix)]
7109        mode: 0,
7110        #[cfg(unix)]
7111        change_seconds: 0,
7112        #[cfg(unix)]
7113        change_nanoseconds: 0,
7114    }
7115}
7116
7117fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
7118    let metadata = match std::fs::symlink_metadata(path) {
7119        Ok(metadata) => metadata,
7120        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7121        Err(e) => {
7122            return Err(spar_err!(
7123                "could not inspect the nested repository at {}: {e}",
7124                path.display()
7125            ))
7126        }
7127    };
7128    if !metadata.is_dir() {
7129        bail!(
7130            "git reported {} as a nested repository, but it is not a directory",
7131            path.display()
7132        );
7133    }
7134    if std::fs::symlink_metadata(path.join(".git")).is_err() {
7135        bail!(
7136            "git reported {} as a nested repository, but it has no Git entry",
7137            path.display()
7138        );
7139    }
7140    #[cfg(unix)]
7141    {
7142        use std::os::unix::fs::MetadataExt;
7143        Ok(UntrackedFile {
7144            kind: 3,
7145            len: 0,
7146            modified: None,
7147            created: metadata.created().ok(),
7148            readonly: metadata.permissions().readonly(),
7149            symlink_target: None,
7150            device: metadata.dev(),
7151            inode: metadata.ino(),
7152            mode: metadata.mode(),
7153            change_seconds: 0,
7154            change_nanoseconds: 0,
7155        })
7156    }
7157    #[cfg(not(unix))]
7158    {
7159        Ok(UntrackedFile {
7160            kind: 3,
7161            len: 0,
7162            modified: None,
7163            created: metadata.created().ok(),
7164            readonly: metadata.permissions().readonly(),
7165            symlink_target: None,
7166        })
7167    }
7168}
7169
7170fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
7171    let metadata = match std::fs::symlink_metadata(path) {
7172        Ok(metadata) => metadata,
7173        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7174        Err(e) => {
7175            return Err(spar_err!(
7176                "could not inspect untracked file {}: {e}",
7177                path.display()
7178            ))
7179        }
7180    };
7181    let kind = if metadata.file_type().is_symlink() {
7182        2
7183    } else if metadata.is_file() {
7184        1
7185    } else {
7186        bail!(
7187            "untracked path {} is not a regular file or symlink",
7188            path.display()
7189        );
7190    };
7191    let symlink_target = if kind == 2 {
7192        match std::fs::read_link(path) {
7193            Ok(target) => Some(os_str_bytes(target.as_os_str())?),
7194            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7195                return Ok(vanished_untracked_file())
7196            }
7197            Err(e) => {
7198                return Err(spar_err!(
7199                    "could not read untracked symlink {}: {e}",
7200                    path.display()
7201                ))
7202            }
7203        }
7204    } else {
7205        None
7206    };
7207    #[cfg(unix)]
7208    {
7209        use std::os::unix::fs::MetadataExt;
7210        Ok(UntrackedFile {
7211            kind,
7212            len: metadata.len(),
7213            modified: metadata.modified().ok(),
7214            created: metadata.created().ok(),
7215            readonly: metadata.permissions().readonly(),
7216            symlink_target,
7217            device: metadata.dev(),
7218            inode: metadata.ino(),
7219            mode: metadata.mode(),
7220            change_seconds: metadata.ctime(),
7221            change_nanoseconds: metadata.ctime_nsec(),
7222        })
7223    }
7224    #[cfg(not(unix))]
7225    {
7226        Ok(UntrackedFile {
7227            kind,
7228            len: metadata.len(),
7229            modified: metadata.modified().ok(),
7230            created: metadata.created().ok(),
7231            readonly: metadata.permissions().readonly(),
7232            symlink_target,
7233        })
7234    }
7235}
7236
7237#[cfg(unix)]
7238fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7239    use std::os::unix::ffi::OsStrExt;
7240    Ok(value.as_bytes().to_vec())
7241}
7242
7243#[cfg(not(unix))]
7244fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7245    value
7246        .to_str()
7247        .map(|value| value.as_bytes().to_vec())
7248        .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
7249}
7250
7251#[cfg(unix)]
7252fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7253    use std::os::unix::fs::MetadataExt;
7254    right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
7255}
7256
7257#[cfg(not(unix))]
7258fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7259    right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
7260}
7261
7262#[derive(Debug, Clone, serde::Serialize, Deserialize)]
7263pub struct BranchRecord {
7264    pub kind: String,
7265    pub number: i64,
7266}
7267
7268/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
7269/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
7270pub fn review_ref(number: i64) -> String {
7271    format!("refs/spar/pr-{number}")
7272}
7273
7274pub fn is_finished(state: &str) -> bool {
7275    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
7276}
7277
7278/// Write text through a temporary file and rename, so a kill cannot leave a
7279/// truncated file behind.
7280///
7281/// The follow-up queue is the one file spar rewrites in place rather than
7282/// appends to, and a truncated queue is lost work: what it held was never
7283/// written anywhere else.
7284pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
7285    if let Some(parent) = path.parent() {
7286        std::fs::create_dir_all(parent)
7287            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
7288    }
7289    // The extension defaults to `json` so `clear_state`, which removes a
7290    // leftover `pr-N.json.tmp` by name, keeps finding the one this wrote.
7291    let tmp = path.with_extension(format!(
7292        "{}.tmp",
7293        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
7294    ));
7295    std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
7296    std::fs::rename(&tmp, path)
7297        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
7298    Ok(())
7299}
7300
7301/// Whether a follow-up file already has an entry under this exact title.
7302///
7303/// Whole heading lines, case insensitively. A substring check answered yes
7304/// for "Retry loop" against "## Retry loop never terminates", which is a
7305/// different defect, and the new one was dropped with a line saying it was
7306/// already noted.
7307pub fn has_heading(text: &str, title: &str) -> bool {
7308    let wanted = title.trim().to_lowercase();
7309    if wanted.is_empty() {
7310        return false;
7311    }
7312    text.lines()
7313        .filter_map(|line| line.trim().strip_prefix("## "))
7314        .any(|heading| heading.trim().to_lowercase() == wanted)
7315}
7316
7317/// Write JSON through a temporary file and rename, so a kill cannot leave a
7318/// truncated state file behind.
7319pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
7320    write_text_atomic(path, &serde_json::to_string_pretty(value)?)
7321}
7322
7323/// One row of `gh pr list --json number,url,title,baseRefName,closingIssuesReferences`.
7324#[derive(Deserialize)]
7325#[serde(rename_all = "camelCase")]
7326pub struct LinkedPrRow {
7327    number: i64,
7328    #[serde(default)]
7329    url: String,
7330    #[serde(default)]
7331    title: String,
7332    #[serde(default)]
7333    closing_issues_references: Vec<IssueRef>,
7334}
7335
7336/// The listed pull requests, or `None` when the payload could not be read.
7337///
7338/// Separated from the gh call so the real payload shape can be tested, and kept
7339/// distinct from an empty list, because silently failing to parse would look
7340/// exactly like "no pull request exists", which is the answer that makes spar
7341/// implement over the top of somebody's work.
7342pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
7343    serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
7344}
7345
7346/// Among the listed pull requests, the first that would close `issue`.
7347pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
7348    rows.iter()
7349        .find(|row| {
7350            row.closing_issues_references
7351                .iter()
7352                .any(|linked| linked.number == issue)
7353        })
7354        .map(|row| PrRef {
7355            number: row.number,
7356            url: row.url.clone(),
7357            title: row.title.clone(),
7358        })
7359}
7360
7361/// Both halves together, for the tests that hold the real payload.
7362pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
7363    pick_linked_pr(&linked_pr_rows(json)?, issue)
7364}
7365
7366/// Flatten whatever `gh api --paginate` printed into a list of comments.
7367///
7368/// Current gh merges array pages into one array. Older builds concatenated one
7369/// document per page. A streaming parser reads either, and unlike splitting the
7370/// text on a bracket pair it cannot be fooled by a comment body that happens to
7371/// contain one, which would otherwise make a resume silently start over.
7372fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
7373    if text.trim().is_empty() {
7374        return Err(spar_err!("GitHub returned no comment data"));
7375    }
7376    let mut out = Vec::new();
7377    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7378        match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
7379            Value::Array(items) => out.extend(items),
7380            _ => return Err(spar_err!("unexpected non-array comment page")),
7381        }
7382    }
7383    Ok(out)
7384}
7385
7386pub fn parse_comment_pages(text: &str) -> Vec<Value> {
7387    let mut out = Vec::new();
7388    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7389        match value {
7390            Ok(Value::Array(items)) => out.extend(items),
7391            Ok(other) => out.push(other),
7392            Err(_) => break,
7393        }
7394    }
7395    out
7396}
7397
7398/// Whether a comment is a state block spar itself wrote.
7399///
7400/// The body has to open with the marker, so a person quoting it in a sentence
7401/// is not one; it has to parse, so a half-quoted block is not one; and it has
7402/// to be the viewer's own comment, the way `comments.rs` already tells spar's
7403/// writes apart. Anything less and `write_pr_state` patches somebody's comment
7404/// with JSON, and `spar clean --pr-state` later deletes it.
7405pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
7406    body.trim_start().starts_with(STATE_MARKER)
7407        && !viewer.trim().is_empty()
7408        && author.trim().eq_ignore_ascii_case(viewer.trim())
7409        && parse_state_comment(body).is_some()
7410}
7411
7412/// Extract the payload from a state comment. The marker is followed by JSON and
7413/// terminated with `-->`.
7414pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
7415    let marker = body.find(STATE_MARKER)?;
7416    let start = body[marker..].find('{')? + marker;
7417    let end = body.rfind('}')?;
7418    if end <= start {
7419        return None;
7420    }
7421    match serde_json::from_str(&body[start..=end]) {
7422        Ok(state) => Some(state),
7423        Err(_) => {
7424            logdim!("found a spar state comment but could not parse it");
7425            None
7426        }
7427    }
7428}
7429
7430fn choose_state_for_head(
7431    candidates: Vec<PersistedState>,
7432    actual_head: &str,
7433) -> Option<PersistedState> {
7434    let matching: Vec<PersistedState> = candidates
7435        .iter()
7436        .filter(|state| state.pr_head == actual_head)
7437        .cloned()
7438        .collect();
7439    if !matching.is_empty() {
7440        return newest_state(matching);
7441    }
7442    newest_state(candidates)
7443}
7444
7445fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
7446    candidates.into_iter().reduce(|best, candidate| {
7447        if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
7448            candidate
7449        } else {
7450            // The local candidate is supplied first. Keeping the first exact
7451            // tie recovers correctly from a local write followed by a failed
7452            // pull request state update, including legacy states with no
7453            // checkpoint field.
7454            best
7455        }
7456    })
7457}
7458
7459/// Where this binary lives, so `git filter-branch` can call back into it.
7460///
7461/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
7462/// tests, whose `current_exe` is the test harness rather than spar, and for
7463/// anyone who ships spar behind a wrapper script.
7464pub fn self_binary() -> Result<PathBuf> {
7465    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
7466        let path = PathBuf::from(path);
7467        if proc::is_executable(&path) {
7468            return Ok(path);
7469        }
7470        bail!(
7471            "SPAR_SELF_BIN is set to {}, which is not executable",
7472            path.display()
7473        );
7474    }
7475    std::env::current_exe()
7476        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
7477}
7478
7479fn bool_env(value: bool) -> &'static str {
7480    if value {
7481        "1"
7482    } else {
7483        "0"
7484    }
7485}
7486
7487/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
7488/// shell command, and an install path with a space in it is not exotic.
7489pub fn sh_quote(text: &str) -> String {
7490    format!("'{}'", text.replace('\'', r"'\''"))
7491}
7492
7493/// Style rules for the `scrub-filter` subcommand, which runs in a child process
7494/// spawned by git and so cannot see the parent's config.
7495pub fn style_from_env() -> Style {
7496    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
7497    Style {
7498        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
7499        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
7500        ..Style::permissive()
7501    }
7502}
7503
7504#[cfg(test)]
7505mod tests {
7506    use super::*;
7507    use crate::config::StateStore;
7508    use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
7509    use std::process::Command;
7510
7511    fn repo_for_titles() -> Repo {
7512        Repo {
7513            root: PathBuf::from("/nonexistent"),
7514            style: Style::default(),
7515            branch_prefix: String::new(),
7516            state_store: StateStore::Local,
7517            followups: crate::config::Followups::Issues,
7518            drafts: Drafts::Never,
7519            viewer: OnceLock::new(),
7520            checkpoints: Mutex::new(BTreeMap::new()),
7521            writes: WriteStats::default(),
7522        }
7523    }
7524
7525    #[test]
7526    fn write_results_accumulate_for_the_run() {
7527        let repo = repo_for_titles();
7528
7529        let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
7530        let _: std::result::Result<(), ()> = repo.record_write(Err(()));
7531
7532        assert_eq!(
7533            WriteSummary {
7534                attempted: 2,
7535                failed: 1,
7536            },
7537            repo.write_summary()
7538        );
7539    }
7540
7541    #[test]
7542    fn only_failed_write_preflights_join_the_summary() {
7543        let repo = repo_for_titles();
7544
7545        let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
7546        let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
7547
7548        assert_eq!(
7549            WriteSummary {
7550                attempted: 1,
7551                failed: 1,
7552            },
7553            repo.write_summary()
7554        );
7555    }
7556
7557    #[test]
7558    fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
7559        let repo = repo_for_titles();
7560
7561        assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
7562        assert_eq!(
7563            WriteSummary {
7564                attempted: 1,
7565                failed: 1,
7566            },
7567            repo.write_summary()
7568        );
7569    }
7570
7571    #[test]
7572    fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
7573        let mut repo = repo_for_titles();
7574        repo.followups = Followups::Local;
7575
7576        assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
7577        assert_eq!(WriteSummary::default(), repo.write_summary());
7578    }
7579
7580    #[test]
7581    fn a_failed_remote_state_read_stops_before_state_mutation() {
7582        let root = std::env::temp_dir().join(format!(
7583            "spar-state-preflight-{}-{}",
7584            std::process::id(),
7585            std::time::SystemTime::now()
7586                .duration_since(std::time::UNIX_EPOCH)
7587                .unwrap()
7588                .as_nanos()
7589        ));
7590        std::fs::create_dir_all(&root).unwrap();
7591        let _fixture = ReviewFixture { root: root.clone() };
7592        let mut repo = repo_for_titles();
7593        repo.root = root;
7594        repo.state_store = StateStore::Both;
7595        let state = PersistedState {
7596            version: 1,
7597            checkpoint: 4,
7598            round: 2,
7599            next_actor: "a".into(),
7600            status: Status::Pending,
7601            pr_head: "abc123".into(),
7602            ledger: Ledger::new(),
7603            filed: Vec::new(),
7604            open_findings: Vec::new(),
7605            disputes: Vec::new(),
7606            noted: Vec::new(),
7607        };
7608
7609        let error = repo
7610            .write_state_after_remote_read(
7611                7,
7612                &state,
7613                Err(crate::error::SparError::new("state comments unavailable")),
7614            )
7615            .unwrap_err();
7616
7617        assert!(error.to_string().contains("state comments unavailable"));
7618        assert!(!repo.state_path(7).exists());
7619        assert_eq!(0, repo.remembered_checkpoint(7));
7620        assert_eq!(
7621            WriteSummary {
7622                attempted: 1,
7623                failed: 1,
7624            },
7625            repo.write_summary()
7626        );
7627    }
7628
7629    #[test]
7630    fn only_known_build_and_cache_directories_are_generated_artifacts() {
7631        assert!(is_generated_artifact(Path::new("target/debug/artifact")));
7632        assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
7633        assert!(is_generated_artifact(Path::new(
7634            "package/node_modules/dependency/file.js"
7635        )));
7636        assert!(!is_generated_artifact(Path::new(
7637            "distribution/required-package.js"
7638        )));
7639        assert!(!is_generated_artifact(Path::new(
7640            "generated/required-fixture.txt"
7641        )));
7642        assert!(!is_generated_artifact(Path::new("local.env")));
7643    }
7644
7645    struct ReviewFixture {
7646        root: PathBuf,
7647    }
7648
7649    impl Drop for ReviewFixture {
7650        fn drop(&mut self) {
7651            let _ = std::fs::remove_dir_all(&self.root);
7652        }
7653    }
7654
7655    fn test_git(cwd: &Path, args: &[&str]) -> String {
7656        let output = Command::new("git")
7657            .args(args)
7658            .current_dir(cwd)
7659            .output()
7660            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
7661        assert!(
7662            output.status.success(),
7663            "git {args:?} failed: {}",
7664            String::from_utf8_lossy(&output.stderr)
7665        );
7666        String::from_utf8_lossy(&output.stdout).into_owned()
7667    }
7668
7669    fn review_fixture(
7670        tag: &str,
7671        number: i64,
7672    ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
7673        use std::sync::atomic::{AtomicU32, Ordering};
7674        static NEXT: AtomicU32 = AtomicU32::new(0);
7675        let id = NEXT.fetch_add(1, Ordering::Relaxed);
7676        let root =
7677            std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
7678        let origin = root.join("origin.git");
7679        let work = root.join("work");
7680        std::fs::create_dir_all(&origin).unwrap();
7681        std::fs::create_dir_all(&work).unwrap();
7682        test_git(&origin, &["init", "--bare", "-b", "main"]);
7683        test_git(&work, &["init", "-b", "main"]);
7684        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
7685        test_git(&work, &["config", "user.name", "spar test"]);
7686        test_git(&work, &["config", "commit.gpgsign", "false"]);
7687        test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
7688        test_git(&work, &["config", "filter.drop.smudge", "cat"]);
7689        std::fs::write(work.join("README.md"), "seed\n").unwrap();
7690        std::fs::write(work.join("data.txt"), "old\n").unwrap();
7691        std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
7692        std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
7693        test_git(&work, &["add", "."]);
7694        test_git(&work, &["commit", "-m", "seed"]);
7695        test_git(
7696            &work,
7697            &["remote", "add", "origin", origin.to_str().unwrap()],
7698        );
7699        test_git(&work, &["push", "-u", "origin", "main"]);
7700        test_git(
7701            &work,
7702            &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
7703        );
7704        let cfg = crate::config::parse(
7705            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
7706        )
7707        .unwrap();
7708        let repo = Repo::open(&work, &cfg).unwrap();
7709        let path = repo.worktree_for_pr_head(number).unwrap();
7710        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7711        (ReviewFixture { root }, repo, path, checkpoint)
7712    }
7713
7714    #[test]
7715    fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
7716        let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
7717
7718        repo.release_review_worktree_checked(901, &checkpoint)
7719            .unwrap();
7720
7721        assert!(!path.exists());
7722    }
7723
7724    #[test]
7725    fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
7726        let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
7727        let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
7728        std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
7729        test_git(&path, &["add", "recovery.txt"]);
7730        test_git(&path, &["commit", "-m", "recovery commit"]);
7731        let recovery = test_git(&path, &["rev-parse", "HEAD"]);
7732        test_git(&path, &["reset", "--hard", "main"]);
7733
7734        assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
7735        test_git(
7736            &path,
7737            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7738        );
7739    }
7740
7741    #[test]
7742    fn a_review_ref_reflog_only_commit_prevents_deletion() {
7743        let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
7744        let local_ref = review_ref(921);
7745        let original = test_git(&path, &["rev-parse", &local_ref]);
7746        let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
7747        let recovery = test_git(
7748            &path,
7749            &[
7750                "commit-tree",
7751                tree.trim(),
7752                "-p",
7753                original.trim(),
7754                "-m",
7755                "review ref recovery",
7756            ],
7757        );
7758        test_git(
7759            &path,
7760            &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
7761        );
7762        test_git(
7763            &path,
7764            &["update-ref", &local_ref, original.trim(), recovery.trim()],
7765        );
7766
7767        assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
7768        assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
7769        test_git(
7770            &path,
7771            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7772        );
7773    }
7774
7775    #[test]
7776    fn an_unpublished_commit_message_draft_is_recoverable() {
7777        let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
7778        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
7779        let git_dir = if raw.is_absolute() {
7780            raw
7781        } else {
7782            path.join(raw)
7783        };
7784        std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
7785
7786        assert!(repository_has_recoverable_work(&path, true).unwrap());
7787        assert_eq!(
7788            "unique recovery draft\n",
7789            std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
7790        );
7791    }
7792
7793    #[test]
7794    fn a_changed_review_worktree_is_retained_after_a_checked_read() {
7795        let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
7796        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
7797
7798        let error = repo
7799            .release_review_worktree_checked(902, &checkpoint)
7800            .unwrap_err();
7801
7802        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7803        assert!(error.to_string().contains("kept for recovery"), "{error}");
7804        assert_eq!(
7805            "recover me\n",
7806            std::fs::read_to_string(path.join("README.md")).unwrap()
7807        );
7808        repo.release_review_worktree(902);
7809    }
7810
7811    #[test]
7812    fn a_review_commit_is_retained_after_a_checked_read() {
7813        let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
7814        std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
7815        test_git(&path, &["add", "review-note.txt"]);
7816        test_git(&path, &["commit", "-m", "local review recovery"]);
7817        let head = test_git(&path, &["rev-parse", "HEAD"]);
7818
7819        let error = repo
7820            .release_review_worktree_checked(903, &checkpoint)
7821            .unwrap_err();
7822
7823        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7824        assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
7825        assert_eq!(
7826            "recover me\n",
7827            std::fs::read_to_string(path.join("review-note.txt")).unwrap()
7828        );
7829        repo.release_review_worktree(903);
7830    }
7831
7832    /// The read is no longer what refuses: a file the call created and Git was
7833    /// told to ignore is its own output. Removal still refuses, so the file is
7834    /// there to look at, which is the part that mattered.
7835    #[test]
7836    fn an_ignored_review_file_is_retained_after_a_checked_read() {
7837        let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
7838        std::fs::create_dir_all(path.join("generated")).unwrap();
7839        std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
7840
7841        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7842            .expect("its own output is not a change");
7843        let error = repo
7844            .release_review_worktree_checked(904, &checkpoint)
7845            .unwrap_err();
7846
7847        assert!(error.to_string().contains("recoverable"), "{error}");
7848        assert_eq!(
7849            "recover me\n",
7850            std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
7851        );
7852        repo.release_review_worktree(904);
7853    }
7854
7855    #[test]
7856    /// The checkpoint no longer refuses this: a path the project ignores is out
7857    /// of scope for a call that only read. What keeps the file is the gate that
7858    /// actually deletes the checkout, which refuses any ignored file outside a
7859    /// known build directory. That is the check with the stake in it, and it is
7860    /// the one that has to hold.
7861    fn a_preexisting_ignored_review_file_change_is_retained() {
7862        let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
7863        std::fs::create_dir_all(path.join("generated")).unwrap();
7864        let ignored = path.join("generated/recovery.txt");
7865        std::fs::write(&ignored, "before\n").unwrap();
7866        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7867        std::fs::write(&ignored, "after!\n").unwrap();
7868
7869        let error = repo
7870            .release_review_worktree_checked(905, &checkpoint)
7871            .unwrap_err();
7872
7873        assert!(error.to_string().contains("recoverable"), "{error}");
7874        assert!(path.exists());
7875        assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
7876        repo.release_review_worktree(905);
7877    }
7878
7879    #[test]
7880    fn a_preexisting_ignored_review_file_prevents_checked_removal() {
7881        let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
7882        std::fs::create_dir_all(path.join("generated")).unwrap();
7883        let ignored = path.join("generated/recovery.txt");
7884        std::fs::write(&ignored, "keep me\n").unwrap();
7885        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7886
7887        let error = repo
7888            .release_review_worktree_checked(906, &checkpoint)
7889            .unwrap_err();
7890
7891        assert!(error.to_string().contains("recoverable"), "{error}");
7892        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7893    }
7894
7895    #[test]
7896    fn overwriting_a_preexisting_untracked_file_is_detected() {
7897        let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
7898        let untracked = path.join("notes.txt");
7899        std::fs::write(&untracked, "before\n").unwrap();
7900        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7901        std::fs::write(&untracked, "after!\n").unwrap();
7902
7903        let error = repo
7904            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7905            .unwrap_err();
7906
7907        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7908        assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
7909    }
7910
7911    #[test]
7912    fn an_assume_unchanged_edit_is_detected() {
7913        let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
7914        test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
7915        std::fs::write(path.join("README.md"), "hidden\n").unwrap();
7916
7917        let error = repo
7918            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7919            .unwrap_err();
7920
7921        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7922        assert_eq!(
7923            "hidden\n",
7924            std::fs::read_to_string(path.join("README.md")).unwrap()
7925        );
7926    }
7927
7928    #[test]
7929    fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
7930        let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
7931        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7932        test_git(&path, &["add", "README.md"]);
7933        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7934
7935        let error = repo
7936            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7937            .unwrap_err();
7938
7939        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7940        assert_eq!(
7941            b"seed\r\n",
7942            std::fs::read(path.join("README.md")).unwrap().as_slice()
7943        );
7944    }
7945
7946    #[cfg(unix)]
7947    #[test]
7948    fn a_mode_edit_is_detected_when_filemode_is_disabled() {
7949        use std::os::unix::fs::PermissionsExt;
7950
7951        let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
7952        test_git(&path, &["config", "core.filemode", "false"]);
7953        let readme = path.join("README.md");
7954        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7955        permissions.set_mode(0o755);
7956        std::fs::set_permissions(&readme, permissions).unwrap();
7957        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7958
7959        let error = repo
7960            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7961            .unwrap_err();
7962
7963        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7964        assert_eq!(
7965            0o755,
7966            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7967        );
7968    }
7969
7970    #[test]
7971    fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
7972        let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
7973        std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
7974        test_git(&path, &["add", ".gitattributes"]);
7975        test_git(&path, &["commit", "-m", "select data filter"]);
7976        let baseline = repo.worktree_baseline(&path).unwrap();
7977        std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
7978
7979        assert!(repo
7980            .commit_pending_changes(&path, &baseline, "change data", "change data")
7981            .unwrap());
7982        let error = repo
7983            .refuse_unrepresented_tracked_changes(&path, &baseline)
7984            .unwrap_err();
7985
7986        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7987        assert_eq!(
7988            "secret: recover me\nnew\n",
7989            std::fs::read_to_string(path.join("data.txt")).unwrap()
7990        );
7991        assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
7992    }
7993
7994    #[test]
7995    fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
7996        let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
7997        std::fs::create_dir_all(path.join("target")).unwrap();
7998        let untracked = path.join("target/user.yaml");
7999        std::fs::write(&untracked, "user data\n").unwrap();
8000        let baseline = repo.worktree_baseline(&path).unwrap();
8001        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8002
8003        assert!(repo
8004            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8005            .unwrap());
8006
8007        assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
8008        assert_eq!(
8009            "?? target/user.yaml\n",
8010            test_git(&path, &["status", "--short", "--untracked-files=all"])
8011        );
8012        assert!(test_git(
8013            &path,
8014            &[
8015                "ls-tree",
8016                "-r",
8017                "--name-only",
8018                "HEAD",
8019                "--",
8020                "target/user.yaml"
8021            ]
8022        )
8023        .is_empty());
8024    }
8025
8026    #[test]
8027    fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
8028        let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
8029        std::fs::create_dir_all(path.join("target")).unwrap();
8030        let untracked = path.join("target/user.yaml");
8031        std::fs::write(&untracked, "before\n").unwrap();
8032        let baseline = repo.worktree_baseline(&path).unwrap();
8033        let before = test_git(&path, &["rev-parse", "HEAD"]);
8034        std::fs::write(&untracked, "after\n").unwrap();
8035        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8036
8037        let error = repo
8038            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8039            .unwrap_err();
8040
8041        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8042        assert!(error.to_string().contains("target/user.yaml"), "{error}");
8043        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
8044        assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
8045        assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
8046    }
8047
8048    #[test]
8049    fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
8050        let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
8051        let baseline = repo.worktree_baseline(&path).unwrap();
8052        std::fs::create_dir_all(path.join("target")).unwrap();
8053        std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
8054
8055        assert!(repo
8056            .commit_pending_changes(&path, &baseline, "add file", "add file")
8057            .unwrap());
8058
8059        assert_eq!(
8060            "new file\n",
8061            test_git(&path, &["show", "HEAD:target/new.txt"])
8062        );
8063        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8064    }
8065
8066    #[test]
8067    fn deleting_existing_ignored_work_stops_a_managed_commit() {
8068        let (_fixture, repo, path, _checkpoint) = review_fixture("deleted-ignored", 912);
8069        std::fs::create_dir_all(path.join("generated")).unwrap();
8070        let ignored = path.join("generated/keep.txt");
8071        std::fs::write(&ignored, "user data\n").unwrap();
8072        let baseline = repo.worktree_baseline(&path).unwrap();
8073        let before = test_git(&path, &["rev-parse", "HEAD"]);
8074        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8075        std::fs::remove_file(&ignored).unwrap();
8076
8077        let error = repo
8078            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8079            .unwrap_err();
8080
8081        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8082        assert!(error.to_string().contains("existing untracked"), "{error}");
8083        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
8084        assert_eq!(
8085            "tracked change\n",
8086            std::fs::read_to_string(path.join("README.md")).unwrap()
8087        );
8088    }
8089
8090    #[test]
8091    fn new_ignored_work_stops_a_managed_commit_with_tracked_changes() {
8092        let (_fixture, repo, path, _checkpoint) = review_fixture("mixed-ignored", 926);
8093        let baseline = repo.worktree_baseline(&path).unwrap();
8094        let before = test_git(&path, &["rev-parse", "HEAD"]);
8095        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8096        std::fs::create_dir_all(path.join("generated")).unwrap();
8097        let ignored = path.join("generated/recovery.txt");
8098        std::fs::write(&ignored, "keep me\n").unwrap();
8099
8100        let error = repo
8101            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8102            .unwrap_err();
8103
8104        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8105        assert!(error.to_string().contains("recovery.txt"), "{error}");
8106        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
8107        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
8108        assert!(test_git(&path, &["status", "--porcelain"])
8109            .lines()
8110            .any(|line| line == "M  README.md"));
8111    }
8112
8113    #[test]
8114    fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
8115        let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
8116        test_git(&path, &["config", "core.autocrlf", "true"]);
8117        std::fs::write(path.join("README.md"), "seed\n").unwrap();
8118        assert_eq!(
8119            test_git(&path, &["hash-object", "README.md"]).trim(),
8120            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8121        );
8122
8123        assert!(repository_has_recoverable_work(&path, true).unwrap());
8124        assert_eq!(
8125            "seed\n",
8126            std::fs::read_to_string(path.join("README.md")).unwrap()
8127        );
8128    }
8129
8130    #[test]
8131    fn autocrlf_input_overrides_a_crlf_core_eol() {
8132        let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
8133        test_git(&path, &["config", "core.autocrlf", "input"]);
8134        test_git(&path, &["config", "core.eol", "crlf"]);
8135        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
8136        assert_eq!(
8137            test_git(&path, &["hash-object", "README.md"]).trim(),
8138            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8139        );
8140
8141        assert!(repository_has_recoverable_work(&path, true).unwrap());
8142        assert_eq!(
8143            b"seed\r\n",
8144            std::fs::read(path.join("README.md")).unwrap().as_slice()
8145        );
8146    }
8147
8148    #[cfg(unix)]
8149    #[test]
8150    fn a_non_executable_permission_change_is_recoverable() {
8151        use std::os::unix::fs::PermissionsExt;
8152
8153        let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
8154        let readme = path.join("README.md");
8155        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
8156        permissions.set_mode(0o600);
8157        std::fs::set_permissions(&readme, permissions).unwrap();
8158        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8159
8160        let error = repo
8161            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8162            .unwrap_err();
8163
8164        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8165        assert!(repository_has_recoverable_work(&path, true).unwrap());
8166        assert_eq!(
8167            0o600,
8168            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
8169        );
8170    }
8171
8172    #[cfg(unix)]
8173    #[test]
8174    fn a_managed_commit_skips_signing_and_hooks() {
8175        use std::os::unix::fs::PermissionsExt;
8176
8177        let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
8178        let common = common_git_dir(&path).unwrap();
8179        let hook = common.join("hooks/pre-commit");
8180        let marker = fixture.root.join("hook-ran");
8181        std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
8182        std::fs::write(
8183            &hook,
8184            format!(
8185                "#!/bin/sh\nprintf ran > {}\nexit 1\n",
8186                sh_quote(marker.to_str().unwrap())
8187            ),
8188        )
8189        .unwrap();
8190        let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
8191        permissions.set_mode(0o755);
8192        std::fs::set_permissions(&hook, permissions).unwrap();
8193        test_git(&path, &["config", "commit.gpgsign", "true"]);
8194        test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
8195        std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
8196        test_git(&path, &["add", "managed.txt"]);
8197
8198        repo.commit_staged_changes(&path, "record managed change")
8199            .unwrap();
8200
8201        assert!(!marker.exists());
8202        assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
8203    }
8204
8205    #[test]
8206    fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
8207        let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
8208        std::fs::write(
8209            path.join(".gitattributes"),
8210            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
8211        )
8212        .unwrap();
8213        test_git(&path, &["add", ".gitattributes"]);
8214        test_git(&path, &["commit", "-m", "select automatic text"]);
8215        test_git(&path, &["config", "core.autocrlf", "true"]);
8216
8217        assert!(repository_has_recoverable_work(&path, true).unwrap());
8218    }
8219
8220    #[test]
8221    fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
8222        let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
8223        std::fs::write(
8224            path.join(".gitattributes"),
8225            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
8226        )
8227        .unwrap();
8228        test_git(&path, &["add", ".gitattributes"]);
8229        test_git(&path, &["commit", "-m", "select ident expansion"]);
8230        std::fs::write(path.join("README.md"), "seed\n").unwrap();
8231
8232        assert!(repository_has_recoverable_work(&path, true).unwrap());
8233    }
8234
8235    /// Add ignore rules the way SPAR does, without a tracked change the
8236    /// worktree would then be kept for.
8237    /// The failure this whole rule was rewritten for: a reviewer ran the
8238    /// project's binding generator to check a finding, and the lockfile it left
8239    /// beside its own manifest matched no directory name spar knew.
8240    #[test]
8241    fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
8242        let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
8243        exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
8244        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8245        std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
8246        std::fs::write(
8247            path.join("bindings/uniffi-bindgen/Cargo.lock"),
8248            "# generated\n",
8249        )
8250        .unwrap();
8251
8252        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8253            .expect("a path the project ignores is the call's own output");
8254    }
8255
8256    #[test]
8257    fn an_attribute_file_under_build_output_is_not_a_filter_change() {
8258        let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
8259        exclude_paths(&repo, &["target/"]);
8260        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8261        std::fs::create_dir_all(path.join("target/package")).unwrap();
8262        std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
8263
8264        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8265            .expect("an ignored attribute file governs only ignored paths");
8266    }
8267
8268    /// Two reviewers share one checkout. The marker written when the first
8269    /// one's answer was refused used to be a brand new untracked file in the
8270    /// snapshot the second one was compared against.
8271    #[test]
8272    fn a_recovery_marker_is_not_a_change_to_the_worktree() {
8273        let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
8274        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8275        let _ = uncertain_worktree_change(&path, "an earlier call was refused");
8276
8277        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8278            .expect("spar's own marker is not something a call did");
8279    }
8280
8281    #[test]
8282    fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
8283        let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
8284
8285        let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
8286            .expect("a path a build removed is not an unreadable worktree");
8287
8288        assert_eq!(vanished_untracked_file(), gone);
8289    }
8290
8291    /// A path that goes is still a path that was there. Dropping it from the
8292    /// snapshot would let it pass for one the call never touched.
8293    #[test]
8294    fn a_vanished_path_is_not_recorded_as_absent() {
8295        let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
8296        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
8297        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8298        std::fs::remove_file(path.join("notes.txt")).unwrap();
8299
8300        let error = repo
8301            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8302            .unwrap_err();
8303
8304        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8305    }
8306
8307    fn exclude_paths(repo: &Repo, lines: &[&str]) {
8308        use std::io::Write;
8309        let path = repo.root().join(".git").join("info").join("exclude");
8310        let mut file = std::fs::OpenOptions::new()
8311            .create(true)
8312            .append(true)
8313            .open(&path)
8314            .unwrap();
8315        for line in lines {
8316            writeln!(file, "{line}").unwrap();
8317        }
8318    }
8319
8320    #[test]
8321    fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
8322        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
8323        exclude_paths(&repo, &["dist/"]);
8324        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8325        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8326        let baseline = repo.worktree_baseline(&path).unwrap();
8327        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8328        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8329        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8330        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8331
8332        assert!(repo
8333            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8334            .unwrap());
8335        assert_eq!(
8336            "tracked change\n",
8337            std::fs::read_to_string(path.join("README.md")).unwrap()
8338        );
8339    }
8340
8341    #[test]
8342    fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
8343        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
8344        exclude_paths(&repo, &["dist/"]);
8345        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8346        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8347        std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
8348        let baseline = repo.worktree_baseline(&path).unwrap();
8349        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8350        std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
8351        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8352        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8353
8354        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8355        repo.refuse_changed_existing_untracked(&path, &baseline)
8356            .unwrap();
8357    }
8358
8359    #[test]
8360    fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
8361        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
8362        exclude_paths(&repo, &["dist/"]);
8363        std::fs::create_dir_all(path.join("dist")).unwrap();
8364        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8365        let baseline = repo.worktree_baseline(&path).unwrap();
8366        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8367        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8368
8369        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8370        repo.refuse_changed_existing_untracked(&path, &baseline)
8371            .unwrap();
8372    }
8373
8374    /// The run this comes from: a reviewer rebuilt a project whose build output
8375    /// lands in `manager/public/assets/`, which no directory list will ever
8376    /// name. The guard inside the call kept the review. This check then called
8377    /// the same thirteen files unrepresentable and ended the round, five
8378    /// minutes of review thrown away over a path Git had been told to disown.
8379    /// The run this comes from: `gh pr merge --delete-branch` could not delete
8380    /// `issue-784` because `.spar-worktrees/issue-784`, left by the run that
8381    /// opened the pull request, still had it checked out.
8382    #[test]
8383    fn the_worktree_holding_a_branch_is_found_by_its_branch_name() {
8384        let (_fixture, repo, _path, _checkpoint) = review_fixture("holding", 950);
8385        let held = repo.root().join(WORKTREE_DIR).join("issue-950");
8386        std::fs::create_dir_all(held.parent().unwrap()).unwrap();
8387        test_git(
8388            repo.root(),
8389            &[
8390                "worktree",
8391                "add",
8392                "-b",
8393                "issue-950",
8394                held.to_str().unwrap(),
8395                "main",
8396            ],
8397        );
8398
8399        let found = repo
8400            .worktree_holding("issue-950")
8401            .expect("the branch is checked out somewhere");
8402
8403        assert!(found.ends_with("issue-950"), "{}", found.display());
8404        assert_eq!(None, repo.worktree_holding("issue-951"));
8405    }
8406
8407    #[test]
8408    fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
8409        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
8410        exclude_paths(&repo, &["manager/public/assets/"]);
8411        std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
8412        let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
8413        std::fs::write(&bundle, "first build\n").unwrap();
8414        let baseline = repo.worktree_baseline(&path).unwrap();
8415        std::fs::write(&bundle, "second build\n").unwrap();
8416        std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
8417
8418        repo.refuse_read_only_leavings(&path, &baseline).unwrap();
8419        // The commit-side rule still refuses it, which is exactly why a call
8420        // that committed nothing must not be asked that question.
8421        assert!(repo.refuse_new_ignored_files(&path, &baseline).is_err());
8422    }
8423
8424    #[test]
8425    fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
8426        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
8427        exclude_paths(&repo, &["dist/"]);
8428        let baseline = repo.worktree_baseline(&path).unwrap();
8429        std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
8430
8431        let error = repo
8432            .refuse_read_only_leavings(&path, &baseline)
8433            .unwrap_err();
8434
8435        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8436        assert!(error.to_string().contains("scratch.md"), "{error}");
8437    }
8438
8439    #[test]
8440    fn a_new_ignored_file_outside_build_output_still_stops_a_call() {
8441        let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
8442        exclude_paths(&repo, &["dist/", ".env.local"]);
8443        std::fs::create_dir_all(path.join("dist")).unwrap();
8444        let baseline = repo.worktree_baseline(&path).unwrap();
8445        std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
8446        std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
8447
8448        let error = repo.refuse_new_ignored_files(&path, &baseline).unwrap_err();
8449
8450        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8451        assert!(error.to_string().contains(".env.local"), "{error}");
8452    }
8453
8454    #[test]
8455    fn a_read_only_inspection_may_rebuild_generated_output() {
8456        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
8457        exclude_paths(&repo, &["dist/"]);
8458        std::fs::create_dir_all(path.join("dist")).unwrap();
8459        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8460        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8461        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8462        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8463
8464        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8465            .unwrap();
8466    }
8467
8468    #[test]
8469    fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
8470        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
8471        exclude_paths(&repo, &["dist/", ".env.local"]);
8472        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
8473        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8474        std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
8475
8476        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8477            .unwrap();
8478    }
8479
8480    #[test]
8481    fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
8482        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
8483        exclude_paths(&repo, &["dist/", ".env.local"]);
8484        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
8485        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8486        std::fs::remove_file(path.join(".env.local")).unwrap();
8487
8488        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8489            .unwrap();
8490    }
8491
8492    #[test]
8493    fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
8494        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
8495        exclude_paths(&repo, &["dist/"]);
8496        std::fs::write(path.join("notes.md"), "before\n").unwrap();
8497        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8498        std::fs::write(path.join("notes.md"), "after\n").unwrap();
8499
8500        let error = repo
8501            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8502            .unwrap_err();
8503
8504        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8505        assert!(error.to_string().contains("notes.md"), "{error}");
8506    }
8507
8508    #[test]
8509    fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
8510        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
8511        exclude_paths(&repo, &["dist/", "local.env"]);
8512        std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
8513        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8514        // Stop ignoring it, so the same bytes are now an ordinary untracked
8515        // file. What a path is counts, not only whether it moved.
8516        std::fs::write(
8517            repo.root().join(".git").join("info").join("exclude"),
8518            "dist/\n",
8519        )
8520        .unwrap();
8521
8522        let error = repo
8523            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8524            .unwrap_err();
8525
8526        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8527        assert!(error.to_string().contains("local.env"), "{error}");
8528    }
8529
8530    #[test]
8531    fn a_read_only_inspection_names_the_tracked_file_that_moved() {
8532        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
8533        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8534        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
8535
8536        let error = repo
8537            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8538            .unwrap_err();
8539
8540        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8541        assert!(error.to_string().contains("README.md"), "{error}");
8542    }
8543
8544    #[test]
8545    fn build_output_alone_does_not_keep_a_worktree() {
8546        let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
8547        exclude_paths(&repo, &["target/", "dist/"]);
8548        std::fs::create_dir_all(path.join("target/debug")).unwrap();
8549        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8550        std::fs::create_dir_all(path.join("dist/cli")).unwrap();
8551        std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
8552
8553        assert!(!repository_has_recoverable_work(&path, true).unwrap());
8554        repo.release_review_worktree(933);
8555
8556        assert!(!path.exists());
8557    }
8558
8559    #[test]
8560    fn an_ignored_file_outside_build_output_keeps_a_worktree() {
8561        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
8562        exclude_paths(&repo, &["target/", ".env.local"]);
8563        std::fs::create_dir_all(path.join("target/debug")).unwrap();
8564        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8565        std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
8566
8567        assert!(repository_has_recoverable_work(&path, true).unwrap());
8568        repo.release_review_worktree(934);
8569
8570        assert_eq!(
8571            "TOKEN=keep me\n",
8572            std::fs::read_to_string(path.join(".env.local")).unwrap()
8573        );
8574    }
8575
8576    #[test]
8577    fn a_repository_nested_in_build_output_keeps_a_worktree() {
8578        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
8579        exclude_paths(&repo, &["node_modules/"]);
8580        let nested = path.join("node_modules/local-dep");
8581        std::fs::create_dir_all(&nested).unwrap();
8582        test_git(&nested, &["init"]);
8583        std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
8584
8585        assert!(repository_has_recoverable_work(&path, true).unwrap());
8586        repo.release_review_worktree(935);
8587
8588        assert!(nested.join(".git").exists());
8589    }
8590
8591    #[test]
8592    fn an_ordinary_untracked_file_keeps_a_worktree() {
8593        let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
8594        std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
8595
8596        assert!(repository_has_recoverable_work(&path, true).unwrap());
8597        repo.release_review_worktree(936);
8598
8599        assert_eq!(
8600            "somebody's notes\n",
8601            std::fs::read_to_string(path.join("notes.md")).unwrap()
8602        );
8603    }
8604
8605    #[test]
8606    fn a_legacy_crlf_checkout_is_retained_conservatively() {
8607        let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
8608        std::fs::write(
8609            path.join(".gitattributes"),
8610            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
8611        )
8612        .unwrap();
8613        test_git(&path, &["add", ".gitattributes"]);
8614        test_git(&path, &["commit", "-m", "select legacy line endings"]);
8615
8616        assert!(repository_has_recoverable_work(&path, true).unwrap());
8617    }
8618
8619    #[test]
8620    fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
8621        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
8622        let nested = path.join("tracked");
8623        std::fs::create_dir_all(&nested).unwrap();
8624        std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
8625        test_git(&path, &["add", "tracked/seed.txt"]);
8626        test_git(&path, &["commit", "-m", "add tracked directory"]);
8627        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8628        test_git(&nested, &["init"]);
8629
8630        let error = repo
8631            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8632            .unwrap_err();
8633
8634        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8635        assert!(error.to_string().contains("Git entry"), "{error}");
8636        assert!(nested.join(".git").exists());
8637    }
8638
8639    /// A reviewer that clones a reproduction into ignored build output used to
8640    /// lose the whole review. The state check refused every Git entry it had
8641    /// not put there, including the one Git reports as a nested checkout and
8642    /// the untracked snapshot already records by identity.
8643    #[test]
8644    fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
8645        let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
8646        exclude_paths(&repo, &["target/"]);
8647        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8648        let repro = path.join("target/pr918-repro");
8649        std::fs::create_dir_all(&repro).unwrap();
8650        test_git(&repro, &["init"]);
8651        std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
8652
8653        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8654            .unwrap();
8655
8656        assert!(repository_has_recoverable_work(&path, true).unwrap());
8657        assert!(repro.join(".git").exists());
8658    }
8659
8660    /// What sits under a nested checkout is that repository's business, so the
8661    /// scan stops at its directory rather than reading a checkout of its own
8662    /// as an entry the outer worktree has to account for.
8663    #[test]
8664    fn a_repository_inside_a_nested_checkout_is_left_alone() {
8665        let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
8666        exclude_paths(&repo, &["target/"]);
8667        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8668        let repro = path.join("target/repro");
8669        let vendored = repro.join("vendor/dep");
8670        std::fs::create_dir_all(&vendored).unwrap();
8671        test_git(&repro, &["init"]);
8672        test_git(&vendored, &["init"]);
8673
8674        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8675            .unwrap();
8676
8677        assert!(vendored.join(".git").exists());
8678    }
8679
8680    #[test]
8681    fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
8682        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
8683
8684        let state = ignored_untracked_state(repo.root()).unwrap();
8685
8686        let relative = path.strip_prefix(repo.root()).unwrap();
8687        assert!(
8688            state.files.contains_key(relative),
8689            "{:?}",
8690            state.files.keys().collect::<Vec<_>>()
8691        );
8692        assert!(state.is_ignored(relative));
8693    }
8694
8695    #[test]
8696    fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
8697        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
8698        let baseline = repo.worktree_baseline(repo.root()).unwrap();
8699        std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
8700        std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
8701
8702        repo.refuse_new_ignored_files(repo.root(), &baseline)
8703            .unwrap();
8704        repo.refuse_changed_existing_untracked(repo.root(), &baseline)
8705            .unwrap();
8706    }
8707
8708    #[test]
8709    fn deleting_a_resident_worktree_during_a_call_is_refused() {
8710        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
8711        let baseline = repo.worktree_baseline(repo.root()).unwrap();
8712        std::fs::remove_dir_all(&path).unwrap();
8713
8714        let error = repo
8715            .refuse_new_ignored_files(repo.root(), &baseline)
8716            .unwrap_err();
8717
8718        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8719        assert!(error.to_string().contains("review-932"), "{error}");
8720    }
8721
8722    #[test]
8723    fn a_nested_repository_record_is_read_as_a_plain_path() {
8724        let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
8725        assert_eq!(Path::new("vendor/checkout"), path);
8726        assert!(nested);
8727
8728        let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
8729        assert_eq!(Path::new("vendor/notes.txt"), path);
8730        assert!(!nested);
8731
8732        assert!(untracked_record(b"/", "untracked").is_err());
8733    }
8734
8735    #[cfg(unix)]
8736    #[test]
8737    fn a_non_utf8_git_path_is_preserved_without_loss() {
8738        use std::os::unix::ffi::OsStrExt;
8739
8740        let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
8741
8742        assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
8743    }
8744
8745    #[test]
8746    fn guarded_merge_pins_the_reviewed_head() {
8747        let args = merge_pr_args("36", Some("abc123"), true);
8748        assert_eq!(
8749            vec![
8750                "pr",
8751                "merge",
8752                "36",
8753                "--squash",
8754                "--delete-branch",
8755                "--match-head-commit",
8756                "abc123"
8757            ],
8758            args
8759        );
8760    }
8761
8762    #[test]
8763    fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
8764        let pr = PrRef {
8765            number: 7,
8766            url: "https://example.test/pull/7".into(),
8767            title: "part one".into(),
8768        };
8769        let result = reconcile_pr_creation(
8770            "split-34-1",
8771            Err(crate::error::SparError::new("connection lost")),
8772            Ok(Some(pr)),
8773        )
8774        .unwrap();
8775        assert_eq!(7, result.number);
8776    }
8777
8778    #[test]
8779    fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
8780        let error = reconcile_pr_creation(
8781            "split-34-1",
8782            Err(crate::error::SparError::new("permission denied")),
8783            Ok(None),
8784        )
8785        .unwrap_err();
8786        assert!(error.to_string().contains("permission denied"), "{error}");
8787    }
8788
8789    #[test]
8790    fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
8791        let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
8792        assert!(pr_for_base(text, "split-34-2", "split-34-1")
8793            .unwrap()
8794            .is_none());
8795        let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
8796        assert_eq!(7, found.number);
8797    }
8798
8799    #[test]
8800    fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
8801        let result = reconcile_comment_post(
8802            34,
8803            "the summary",
8804            crate::error::SparError::new("connection lost"),
8805            Ok(vec![serde_json::json!({"body": "the summary"})]),
8806        );
8807        assert!(result.is_ok(), "{result:?}");
8808    }
8809
8810    #[test]
8811    fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
8812        let error = reconcile_comment_post(
8813            34,
8814            "the summary",
8815            crate::error::SparError::new("connection lost"),
8816            Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
8817        )
8818        .unwrap_err();
8819        assert_eq!("connection lost", error.to_string());
8820    }
8821
8822    #[test]
8823    fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
8824        let error = reconcile_comment_post(
8825            34,
8826            "the summary",
8827            crate::error::SparError::new("connection lost"),
8828            Err(crate::error::SparError::new("comments unavailable")),
8829        )
8830        .unwrap_err();
8831        assert!(
8832            error.to_string().contains("could not be verified"),
8833            "{error}"
8834        );
8835        assert!(
8836            error.to_string().contains("comments unavailable"),
8837            "{error}"
8838        );
8839        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8840        assert!(!error.worth_retrying());
8841    }
8842
8843    #[test]
8844    fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
8845        let result = reconcile_issue_edit(
8846            34,
8847            "wanted body",
8848            crate::error::SparError::new("connection lost"),
8849            Ok("wanted body".to_string()),
8850        );
8851        assert!(result.is_ok(), "{result:?}");
8852    }
8853
8854    #[test]
8855    fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
8856        let error = reconcile_issue_edit(
8857            34,
8858            "wanted body",
8859            crate::error::SparError::new("connection lost"),
8860            Err(crate::error::SparError::new("issue unavailable")),
8861        )
8862        .unwrap_err();
8863        assert!(
8864            error.to_string().contains("could not be verified"),
8865            "{error}"
8866        );
8867        assert!(error.to_string().contains("issue unavailable"), "{error}");
8868        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8869        assert!(!error.worth_retrying());
8870    }
8871
8872    #[test]
8873    fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
8874        let found = ExistingIssue {
8875            number: 101,
8876            url: "https://example.test/issues/101".into(),
8877            title: "child".into(),
8878            body: "body".into(),
8879            open: true,
8880        };
8881        let url = reconcile_issue_creation(
8882            "child",
8883            Err(crate::error::SparError::new("connection lost")),
8884            Ok(Some(found)),
8885        )
8886        .unwrap();
8887        assert_eq!("https://example.test/issues/101", url);
8888    }
8889
8890    #[test]
8891    fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
8892        let error = reconcile_issue_creation(
8893            "child",
8894            Err(crate::error::SparError::new("permission denied")),
8895            Ok(None),
8896        )
8897        .unwrap_err();
8898        assert!(error.to_string().contains("permission denied"), "{error}");
8899    }
8900
8901    #[test]
8902    fn an_unverifiable_issue_creation_is_marked_uncertain() {
8903        let error = reconcile_issue_creation(
8904            "child",
8905            Err(crate::error::SparError::new("connection lost")),
8906            Err(crate::error::SparError::new("issues unavailable")),
8907        )
8908        .unwrap_err();
8909        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8910        assert!(!error.worth_retrying());
8911    }
8912
8913    #[test]
8914    fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
8915        let result = reconcile_failed_split_push(
8916            "split-34-1",
8917            crate::error::SparError::new("connection lost"),
8918            Ok("abc123\n".into()),
8919            Ok("abc123\trefs/heads/split-34-1\n".into()),
8920        );
8921        assert!(result.is_ok(), "{result:?}");
8922    }
8923
8924    #[test]
8925    fn a_split_push_collision_is_definite_and_never_overwrites() {
8926        let error = reconcile_failed_split_push(
8927            "split-34-1",
8928            crate::error::SparError::new("lease rejected"),
8929            Ok("abc123\n".into()),
8930            Ok("def456\trefs/heads/split-34-1\n".into()),
8931        )
8932        .unwrap_err();
8933        assert!(!error.retain_worktree());
8934        assert!(
8935            error.to_string().contains("Nothing was overwritten"),
8936            "{error}"
8937        );
8938    }
8939
8940    #[test]
8941    fn an_unreadable_split_push_result_keeps_the_worktree() {
8942        let error = reconcile_failed_split_push(
8943            "split-34-1",
8944            crate::error::SparError::new("connection lost"),
8945            Ok("abc123\n".into()),
8946            Err(crate::error::SparError::new("origin unavailable")),
8947        )
8948        .unwrap_err();
8949        assert!(error.retain_worktree());
8950        assert!(error.to_string().contains("could not confirm"), "{error}");
8951    }
8952
8953    /// Follow-up deduplication compares a title it computed against the title
8954    /// GitHub stored. If those two transforms can disagree, the check never
8955    /// matches and every review round files another copy of the same issue.
8956    #[test]
8957    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
8958        let repo = repo_for_titles();
8959        for raw in [
8960            "Retry loop spins \u{2014} Retry-After parses to zero",
8961            "plain title",
8962            "  spread   over\nlines  ",
8963            "\u{1F916} Generated with something",
8964            &format!("a \u{2014} {}", "very long title ".repeat(20)),
8965            &"x".repeat(300),
8966            &format!("{} \u{2014} end", "y".repeat(88)),
8967            // Exactly the budget, with two spaceless dashes. The scrub turns
8968            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
8969            // character over budget per dash and a second pass clips again,
8970            // producing a different string. Scrub-then-clip cannot.
8971            &{
8972                let tail = "a\u{2014}b c\u{2014}d";
8973                let pad = Style::default().max_title_chars - tail.chars().count();
8974                format!("{}{tail}", "w".repeat(pad))
8975            },
8976        ] {
8977            let once = repo.clean_title(raw).unwrap();
8978            let twice = repo.clean_title(&once).unwrap();
8979            assert_eq!(once, twice, "not idempotent for {raw:?}");
8980            assert!(
8981                once.chars().count() <= repo.style.max_title_chars,
8982                "over budget: {once:?}"
8983            );
8984            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
8985        }
8986    }
8987
8988    #[test]
8989    fn a_title_with_an_em_dash_survives_as_readable_text() {
8990        let repo = repo_for_titles();
8991        assert_eq!(
8992            "Retry loop spins, Retry-After parses to zero",
8993            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
8994                .unwrap()
8995        );
8996    }
8997
8998    #[test]
8999    fn sh_quote_survives_a_quote() {
9000        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
9001    }
9002
9003    #[test]
9004    fn sh_quote_wraps_a_space() {
9005        assert_eq!(
9006            "'/Applications/My App/spar'",
9007            sh_quote("/Applications/My App/spar")
9008        );
9009    }
9010
9011    #[test]
9012    fn finished_states_are_recognised_case_insensitively() {
9013        assert!(is_finished("MERGED"));
9014        assert!(is_finished("closed"));
9015        assert!(!is_finished("OPEN"));
9016        assert!(!is_finished(""));
9017    }
9018
9019    fn state() -> PersistedState {
9020        PersistedState {
9021            version: 1,
9022            checkpoint: 0,
9023            round: 4,
9024            next_actor: "codex".into(),
9025            status: Status::Pending,
9026            pr_head: "abc123".into(),
9027            ledger: Ledger::new(),
9028            filed: vec![],
9029            open_findings: vec![Finding {
9030                severity: Severity::Blocking,
9031                title: "Unchecked error".into(),
9032                detail: "the failure is discarded".into(),
9033                file: "src/a.rs:12".into(),
9034                ..Finding::default()
9035            }],
9036            disputes: vec![Dispute {
9037                title: "Retry limit".into(),
9038                file: "src/net.rs".into(),
9039                reasoning: "the caller already bounds it".into(),
9040            }],
9041            noted: vec![Finding {
9042                severity: Severity::NonBlocking,
9043                title: "Timeout is fixed".into(),
9044                file: "src/config.rs".into(),
9045                ..Finding::default()
9046            }],
9047        }
9048    }
9049
9050    #[test]
9051    fn a_state_comment_round_trips() {
9052        let body = format!(
9053            "{STATE_MARKER}\n{}\n-->",
9054            serde_json::to_string(&state()).unwrap()
9055        );
9056        let back = parse_state_comment(&body).unwrap();
9057        assert_eq!(4, back.round);
9058        assert_eq!("codex", back.next_actor);
9059        assert_eq!("abc123", back.pr_head);
9060        assert_eq!("Unchecked error", back.open_findings[0].title);
9061        assert_eq!("src/net.rs", back.disputes[0].file);
9062        assert_eq!("Timeout is fixed", back.noted[0].title);
9063    }
9064
9065    #[test]
9066    fn old_state_without_new_lists_still_parses() {
9067        let body = format!(
9068            "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
9069             \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
9070        );
9071        let back = parse_state_comment(&body).expect("old state");
9072        assert!(back.open_findings.is_empty());
9073        assert!(back.disputes.is_empty());
9074        assert!(back.noted.is_empty());
9075        assert!(back.pr_head.is_empty());
9076        assert_eq!(0, back.checkpoint);
9077    }
9078
9079    #[test]
9080    fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
9081        let mut local = state();
9082        local.pr_head = "old".into();
9083        local.round = 9;
9084        let mut remote = state();
9085        remote.pr_head = "current".into();
9086        remote.round = 4;
9087
9088        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9089        assert_eq!("current", chosen.pr_head);
9090        assert_eq!(4, chosen.round);
9091    }
9092
9093    #[test]
9094    fn checkpoint_order_breaks_same_round_ties() {
9095        let mut local = state();
9096        local.pr_head = "current".into();
9097        local.round = 4;
9098        local.checkpoint = 8;
9099        let mut remote = local.clone();
9100        remote.checkpoint = 7;
9101        remote.open_findings.clear();
9102
9103        let chosen = choose_state_for_head(vec![local], "current").unwrap();
9104        assert_eq!(8, chosen.checkpoint);
9105
9106        let mut local = state();
9107        local.pr_head = "current".into();
9108        local.round = 4;
9109        local.checkpoint = 8;
9110        let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
9111        assert_eq!(8, chosen.checkpoint);
9112    }
9113
9114    #[test]
9115    fn legacy_same_round_tie_keeps_the_local_checkpoint() {
9116        let mut local = state();
9117        local.pr_head = "current".into();
9118        local.round = 4;
9119        local.open_findings.push(Finding {
9120            title: "local checkpoint".into(),
9121            ..Finding::default()
9122        });
9123        let mut remote = state();
9124        remote.pr_head = "current".into();
9125        remote.round = 4;
9126
9127        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9128        assert_eq!(
9129            "local checkpoint",
9130            chosen.open_findings.last().unwrap().title
9131        );
9132    }
9133
9134    /// It must render as nothing, so PRs are not littered with machine state.
9135    #[test]
9136    fn the_state_block_is_an_html_comment() {
9137        let body = format!(
9138            "{STATE_MARKER}\n{}\n-->",
9139            serde_json::to_string(&state()).unwrap()
9140        );
9141        assert!(body.starts_with("<!--"));
9142        assert!(body.trim_end().ends_with("-->"));
9143        assert!(!body[..body.find('{').unwrap()].contains("-->"));
9144    }
9145
9146    #[test]
9147    fn an_unrelated_json_block_is_not_state() {
9148        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
9149    }
9150
9151    /// Patching and deleting a comment is not something to do on a substring.
9152    ///
9153    /// A maintainer who notices the block and asks about it writes a comment
9154    /// containing `spar:state`, and the loose filter could not tell theirs from
9155    /// spar's. The next `persist` replaced their words with JSON and
9156    /// `spar clean --pr-state` later deleted it.
9157    #[test]
9158    fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
9159        let block = format!(
9160            "{STATE_MARKER}\n{}\n-->",
9161            serde_json::to_string(&state()).unwrap()
9162        );
9163        assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
9164        assert!(
9165            is_state_comment(&block, "Spar-Bot", "spar-bot"),
9166            "a login differs only in case"
9167        );
9168
9169        assert!(
9170            !is_state_comment(
9171                "why is there a `<!-- spar:state` block on this PR?",
9172                "maintainer",
9173                "spar-bot"
9174            ),
9175            "a question about the block was treated as the block"
9176        );
9177        assert!(
9178            !is_state_comment(&block, "maintainer", "spar-bot"),
9179            "somebody else's comment is not spar's to rewrite"
9180        );
9181        assert!(
9182            !is_state_comment(
9183                &format!("here is what spar writes:\n\n{block}"),
9184                "spar-bot",
9185                "spar-bot"
9186            ),
9187            "the marker has to open the body, not appear in it"
9188        );
9189        assert!(
9190            !is_state_comment(
9191                &format!("{STATE_MARKER}\n{{not json\n-->"),
9192                "spar-bot",
9193                "spar-bot"
9194            ),
9195            "a block that does not parse is not state"
9196        );
9197        assert!(
9198            !is_state_comment(&block, "spar-bot", ""),
9199            "an unknown viewer proves nothing about who wrote it"
9200        );
9201    }
9202
9203    #[test]
9204    fn a_malformed_state_comment_is_none_not_a_panic() {
9205        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
9206    }
9207
9208    #[test]
9209    fn atomic_write_leaves_no_temp_file() {
9210        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
9211        let _ = std::fs::remove_dir_all(&dir);
9212        let path = dir.join("state").join("pr-7.json");
9213        write_json_atomic(&path, &state()).unwrap();
9214        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
9215            .unwrap()
9216            .flatten()
9217            .filter_map(|e| e.file_name().to_str().map(str::to_string))
9218            .collect();
9219        assert_eq!(vec!["pr-7.json".to_string()], files);
9220        let _ = std::fs::remove_dir_all(&dir);
9221    }
9222
9223    #[test]
9224    fn atomic_write_overwrites_rather_than_accumulating() {
9225        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
9226        let _ = std::fs::remove_dir_all(&dir);
9227        let path = dir.join("pr-7.json");
9228        for round in 1..4 {
9229            let mut s = state();
9230            s.round = round;
9231            write_json_atomic(&path, &s).unwrap();
9232        }
9233        let back: PersistedState =
9234            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
9235        assert_eq!(3, back.round);
9236        let _ = std::fs::remove_dir_all(&dir);
9237    }
9238
9239    #[test]
9240    fn style_from_env_defaults_to_enforcing() {
9241        std::env::remove_var("SPAR_BAN_EM_DASH");
9242        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
9243        let style = style_from_env();
9244        assert!(style.ban_em_dash && style.ban_ai_attribution);
9245        assert!(
9246            !style.terse,
9247            "the commit filter must not truncate a commit message"
9248        );
9249    }
9250}
9251
9252#[cfg(test)]
9253mod comment_page_tests {
9254    use super::*;
9255
9256    #[test]
9257    fn a_single_merged_array_is_read() {
9258        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
9259        assert_eq!(2, pages.len());
9260        assert_eq!(Some(2), pages[1]["id"].as_i64());
9261    }
9262
9263    #[test]
9264    fn concatenated_pages_from_an_older_gh_are_read_too() {
9265        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
9266        assert_eq!(2, pages.len());
9267    }
9268
9269    /// A comment body containing a bracket pair used to split the payload into
9270    /// two invalid halves, so no state comment was found and a resume silently
9271    /// started from round one.
9272    #[test]
9273    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
9274        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
9275        let pages = parse_comment_pages(text);
9276        assert_eq!(2, pages.len(), "{pages:?}");
9277        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
9278    }
9279
9280    #[test]
9281    fn empty_output_is_no_comments_not_a_panic() {
9282        assert!(parse_comment_pages("").is_empty());
9283        assert!(parse_comment_pages("   ").is_empty());
9284        assert!(parse_comment_pages("[]").is_empty());
9285    }
9286
9287    #[test]
9288    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
9289        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
9290    }
9291
9292    #[test]
9293    fn a_write_postcheck_rejects_truncated_comment_pages() {
9294        let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
9295        assert!(
9296            error.to_string().contains("unexpected comment pages"),
9297            "{error}"
9298        );
9299    }
9300
9301    #[test]
9302    fn a_write_postcheck_rejects_empty_or_non_array_output() {
9303        assert!(try_parse_comment_pages("").is_err());
9304        assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
9305        assert!(try_parse_comment_pages("[]").is_ok());
9306    }
9307
9308    #[test]
9309    fn state_is_found_in_the_last_matching_comment() {
9310        let payload = |round: u32| {
9311            format!(
9312                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
9313            )
9314        };
9315        let text = serde_json::to_string(&serde_json::json!([
9316            {"id": 1, "body": payload(1)},
9317            {"id": 2, "body": "looks good to me"},
9318            {"id": 3, "body": payload(5)},
9319        ]))
9320        .unwrap();
9321        let pages = parse_comment_pages(&text);
9322        let last = pages
9323            .iter()
9324            .rev()
9325            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
9326            .unwrap();
9327        assert_eq!(5, last.round);
9328    }
9329}
9330
9331#[cfg(test)]
9332mod linked_pr_tests {
9333    use super::*;
9334
9335    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
9336    /// It carries an id and a whole repository object per linked issue, and a
9337    /// parser that chokes on those reports "no pull request", which is the one
9338    /// answer that makes spar implement over the top of somebody's work.
9339    const REAL_PAYLOAD: &str = r#"[
9340      {"number":14252,"title":"fix: reject leading-dash branch names",
9341       "url":"https://github.com/cli/cli/pull/14252",
9342       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
9343         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
9344         "url":"https://github.com/cli/cli/issues/14238"}]},
9345      {"number":14217,"title":"another change",
9346       "url":"https://github.com/cli/cli/pull/14217",
9347       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
9348         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
9349         "url":"https://github.com/cli/cli/issues/9761"}]},
9350      {"number":14200,"title":"unlinked work",
9351       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
9352    ]"#;
9353
9354    #[test]
9355    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
9356        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
9357        assert_eq!(14252, pr.number);
9358        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
9359    }
9360
9361    #[test]
9362    fn the_right_pr_is_picked_out_of_several() {
9363        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
9364    }
9365
9366    #[test]
9367    fn an_issue_nobody_is_working_on_finds_nothing() {
9368        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
9369    }
9370
9371    #[test]
9372    fn an_unlinked_pr_is_never_matched() {
9373        // 14200 closes nothing, so no issue number should ever return it.
9374        for issue in [14200, 0, 1] {
9375            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
9376                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
9377            }
9378        }
9379    }
9380
9381    #[test]
9382    fn empty_or_broken_output_is_none_rather_than_a_panic() {
9383        assert!(find_linked_pr("", 1).is_none());
9384        assert!(find_linked_pr("[]", 1).is_none());
9385        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
9386        assert!(find_linked_pr("[{\"number\":", 1).is_none());
9387    }
9388
9389    /// "gh could not answer" and "nobody is working on it" are different
9390    /// answers, and only one of them means implement from scratch.
9391    ///
9392    /// The parse used to fold both into `None`, so an authentication failure or
9393    /// a rate limit read as an empty list and spar opened a second pull request
9394    /// for an issue somebody already had work open on.
9395    #[test]
9396    fn an_unreadable_list_is_not_an_empty_list() {
9397        assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
9398        assert!(linked_pr_rows("[{\"number\":").is_none());
9399        assert!(linked_pr_rows("").is_none());
9400
9401        let empty = linked_pr_rows("[]").expect("an empty list is an answer");
9402        assert!(empty.is_empty());
9403        assert!(pick_linked_pr(&empty, 14238).is_none());
9404
9405        let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
9406        assert_eq!(3, listed.len());
9407        assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
9408    }
9409
9410    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
9411    #[test]
9412    fn pr_view_reads_the_cross_repository_flag() {
9413        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
9414                       "baseRefName":"main","state":"OPEN",
9415                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
9416        let pr: PrView = serde_json::from_str(json).unwrap();
9417        assert!(pr.is_cross_repository);
9418        assert!(pr.is_open());
9419
9420        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
9421        assert!(
9422            !serde_json::from_str::<PrView>(&same_repo)
9423                .unwrap()
9424                .is_cross_repository
9425        );
9426    }
9427}
9428
9429#[cfg(test)]
9430mod min_number_tests {
9431    /// The floor is applied before the cap, which is the order that matters.
9432    /// spar takes the *lowest* numbered open items, so a repository with a tail
9433    /// of old issues would otherwise spend its whole run in the tail: the cap
9434    /// would be filled by the oldest items and the floor would never be
9435    /// reached. Filtering first is what makes the setting do anything.
9436    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
9437        let mut numbers: Vec<i64> = open.to_vec();
9438        numbers.sort_unstable();
9439        if min_number > 0 {
9440            numbers.retain(|n| *n >= min_number);
9441        }
9442        numbers.truncate(limit);
9443        numbers
9444    }
9445
9446    #[test]
9447    fn the_floor_is_applied_before_the_cap_not_after() {
9448        let open = [12, 13, 14, 480, 481, 482];
9449        assert_eq!(vec![480, 481], pick(&open, 2, 480));
9450        // Capping first would have returned the two oldest and then filtered
9451        // them all away, leaving nothing.
9452        assert!(!pick(&open, 2, 480).is_empty());
9453    }
9454
9455    #[test]
9456    fn no_floor_keeps_the_old_behaviour() {
9457        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
9458    }
9459
9460    #[test]
9461    fn the_floor_is_inclusive() {
9462        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
9463    }
9464
9465    #[test]
9466    fn a_floor_above_everything_open_yields_nothing() {
9467        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
9468    }
9469}