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    /// `gh pr merge --delete-branch` exits non-zero when it cannot delete the
4151    /// local branch, which happens *after* the merge has already landed.
4152    /// Treating that as a failure reports work as lost when it is not.
4153    pub fn merge_pr(&self, number: i64) -> Result<()> {
4154        let n = number.to_string();
4155        let merged = match self.gh(&merge_pr_args(&n, None, true)) {
4156            Ok(_) => Ok(()),
4157            Err(e) => {
4158                if self.pr_state(number) == "MERGED" {
4159                    logdim!(
4160                        "PR #{number} merged; branch cleanup did not finish: {}",
4161                        e.last_line()
4162                    );
4163                    Ok(())
4164                } else {
4165                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4166                }
4167            }
4168        };
4169        self.record_write(merged)
4170    }
4171
4172    /// Squash merge only if the pull request still exposes the reviewed head.
4173    pub fn merge_pr_at_head(
4174        &self,
4175        number: i64,
4176        expected_head: &str,
4177        delete_branch: bool,
4178    ) -> Result<()> {
4179        let n = number.to_string();
4180        let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
4181            Ok(_) => Ok(()),
4182            Err(e) => {
4183                if self.pr_state(number) == "MERGED" {
4184                    logdim!(
4185                        "PR #{number} merged; branch cleanup did not finish: {}",
4186                        e.last_line()
4187                    );
4188                    Ok(())
4189                } else {
4190                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4191                }
4192            }
4193        };
4194        self.record_write(merged)
4195    }
4196
4197    // -- follow-ups -------------------------------------------------------
4198
4199    /// The queue of follow-ups recorded locally rather than filed, which
4200    /// `spar followup` works.
4201    pub fn followups_path(&self) -> PathBuf {
4202        self.root.join(STATE_DIR).join("followups.md")
4203    }
4204
4205    /// What `spar followup` already dealt with, kept beside the queue.
4206    ///
4207    /// Two jobs. It is what stops `append_local_followup` re-recording a
4208    /// follow-up whose entry has since left the queue, which would otherwise
4209    /// turn the file into a ring buffer of things already filed. And it keeps
4210    /// the text of an entry a screening pass ruled stale, so a wrong verdict
4211    /// costs a re-read rather than the only copy of a real defect.
4212    pub fn worked_followups_path(&self) -> PathBuf {
4213        self.root.join(STATE_DIR).join("followups.done.md")
4214    }
4215
4216    /// What `spar checkin` has already answered on one pull request or issue.
4217    /// Where what a run spent is kept.
4218    pub fn spend_path(&self) -> PathBuf {
4219        self.root.join(STATE_DIR).join("state").join("spend.json")
4220    }
4221
4222    /// Append this run's calls to the record.
4223    ///
4224    /// Appended rather than replaced, so a resumed pull request adds to the
4225    /// count of what it has cost so far rather than starting it again, and so
4226    /// anybody charting this has a file to chart.
4227    pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
4228        if calls.is_empty() {
4229            return Ok(());
4230        }
4231        let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
4232            .ok()
4233            .and_then(|text| serde_json::from_str(&text).ok())
4234            .unwrap_or_default();
4235        all.extend(calls.iter().cloned());
4236        if let Some(parent) = self.spend_path().parent() {
4237            let _ = std::fs::create_dir_all(parent);
4238        }
4239        write_json_atomic(&self.spend_path(), &all)
4240    }
4241
4242    /// Who wrote the first draft of each issue, by number.
4243    ///
4244    /// Custody alternates within a pull request and the record of it lives in
4245    /// that pull request's state. This is the other question, which nothing
4246    /// answered: over a queue, which model authored what.
4247    pub fn implementors_path(&self) -> PathBuf {
4248        self.root
4249            .join(STATE_DIR)
4250            .join("state")
4251            .join("implementors.json")
4252    }
4253
4254    pub fn read_implementors(&self) -> BTreeMap<i64, String> {
4255        std::fs::read_to_string(self.implementors_path())
4256            .ok()
4257            .and_then(|text| serde_json::from_str(&text).ok())
4258            .unwrap_or_default()
4259    }
4260
4261    /// Best effort: this is a record for a person, not something the loop reads
4262    /// back, so a failure to write it is not a reason to stop.
4263    pub fn remember_implementor(&self, issue: i64, agent: &str) {
4264        let mut known = self.read_implementors();
4265        known.insert(issue, agent.to_string());
4266        if let Some(parent) = self.implementors_path().parent() {
4267            let _ = std::fs::create_dir_all(parent);
4268        }
4269        if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
4270            logdim!("could not record who implemented #{issue}: {e}");
4271        }
4272    }
4273
4274    /// Where the issues triage disagreed about are remembered.
4275    ///
4276    /// One file rather than one per issue: it is read once per run, before any
4277    /// triage call, to decide what not to ask about again.
4278    pub fn contested_path(&self) -> PathBuf {
4279        self.root
4280            .join(STATE_DIR)
4281            .join("state")
4282            .join("contested.json")
4283    }
4284
4285    /// The issues a person has been asked to decide, by number.
4286    pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
4287        std::fs::read_to_string(self.contested_path())
4288            .ok()
4289            .and_then(|text| serde_json::from_str(&text).ok())
4290            .unwrap_or_default()
4291    }
4292
4293    /// Remember an issue triage could not agree on.
4294    ///
4295    /// Best effort: a run that cannot write this asks again next time, which is
4296    /// what it did before this existed.
4297    pub fn remember_contested(&self, items: &[ContestedItem]) {
4298        if items.is_empty() {
4299            return;
4300        }
4301        let mut known = self.read_contested();
4302        for item in items {
4303            known.insert(
4304                item.issue,
4305                Contested {
4306                    title: item.title.clone(),
4307                    positions: item.positions.clone(),
4308                    reasons: item.reasons.clone(),
4309                },
4310            );
4311        }
4312        if let Some(parent) = self.contested_path().parent() {
4313            let _ = std::fs::create_dir_all(parent);
4314        }
4315        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4316            logdim!("could not record the contested issues: {e}");
4317        }
4318    }
4319
4320    /// Ask about them again, because a person has acted on them.
4321    pub fn forget_contested(&self, numbers: &[i64]) {
4322        let mut known = self.read_contested();
4323        let before = known.len();
4324        known.retain(|number, _| !numbers.contains(number));
4325        if known.len() == before {
4326            return;
4327        }
4328        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4329            logdim!("could not update the contested issues: {e}");
4330        }
4331    }
4332
4333    pub fn checkin_state_path(&self, number: i64) -> PathBuf {
4334        self.root
4335            .join(STATE_DIR)
4336            .join("state")
4337            .join(format!("checkin-{number}.json"))
4338    }
4339
4340    /// Append a follow-up to a local note instead of the tracker.
4341    ///
4342    /// Deduplicated on the title, matching the issue path. The body arrives
4343    /// with its provenance already stamped by the caller, so nothing is added
4344    /// here.
4345    ///
4346    /// A write that did not happen is reported as such rather than as a
4347    /// duplicate: the caller settles the point on the strength of this answer,
4348    /// and settling it on a failed write is how a real defect is lost.
4349    ///
4350    /// Both files are checked, because `spar followup` removes an entry from
4351    /// the queue once it has filed it. Checking only the queue would let the
4352    /// next run that rediscovers the same defect append it again, on top of the
4353    /// issue that now exists for it.
4354    pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
4355        let path = self.followups_path();
4356        let heading = format!("## {}", title.trim());
4357        for seen in [&path, &self.worked_followups_path()] {
4358            if let Ok(existing) = std::fs::read_to_string(seen) {
4359                if has_heading(&existing, title) {
4360                    logdim!("follow-up already noted: {title}");
4361                    return Followup::Covered(format!("note: {}", title.trim()));
4362                }
4363            }
4364        }
4365        if let Some(parent) = path.parent() {
4366            let _ = std::fs::create_dir_all(parent);
4367        }
4368        use std::io::Write;
4369        // The caller already stamped the provenance into the body. Adding
4370        // "From #N." here as well printed it twice, in two different wordings.
4371        //
4372        // The marker above the heading is what makes the entry boundary
4373        // unambiguous to the parser, since the body's own sections are written
4374        // at the same heading level as the title.
4375        let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
4376        match std::fs::OpenOptions::new()
4377            .create(true)
4378            .append(true)
4379            .open(&path)
4380        {
4381            Ok(mut file) => match file.write_all(entry.as_bytes()) {
4382                Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
4383                Err(e) => {
4384                    logdim!("could not write {}: {e}", path.display());
4385                    Followup::Failed
4386                }
4387            },
4388            Err(e) => {
4389                logdim!("could not write {}: {e}", path.display());
4390                Followup::Failed
4391            }
4392        }
4393    }
4394
4395    /// Record what `spar followup` did with an entry, and why.
4396    ///
4397    /// Best effort: an archive that could not be written is not a reason to
4398    /// stop, since the entry has already been filed or ruled on.
4399    pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
4400        let path = self.worked_followups_path();
4401        if let Some(parent) = path.parent() {
4402            let _ = std::fs::create_dir_all(parent);
4403        }
4404        use std::io::Write;
4405        let entry = format!(
4406            "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
4407            title.trim(),
4408            body.trim()
4409        );
4410        if let Ok(mut file) = std::fs::OpenOptions::new()
4411            .create(true)
4412            .append(true)
4413            .open(&path)
4414        {
4415            let _ = file.write_all(entry.as_bytes());
4416        }
4417    }
4418
4419    // -- resumable state --------------------------------------------------
4420    //
4421    // Custody cannot be read from GitHub authorship: every agent commits and
4422    // comments as the same git identity, so `author` is always the human who
4423    // ran spar. State is kept on disk by default and can additionally travel in
4424    // a PR comment, which is what lets a run be resumed from another machine.
4425
4426    /// Where a comment spar produced but did not post is kept.
4427    pub fn pending_comment_path(&self, number: i64) -> PathBuf {
4428        self.root
4429            .join(STATE_DIR)
4430            .join("reviews")
4431            .join(format!("pr-{number}.md"))
4432    }
4433
4434    /// Keep a comment spar decided not to post.
4435    ///
4436    /// A dry run that prints and forgets means agreeing with what you read
4437    /// costs a second full review. Saving it makes the whole point of reading
4438    /// it first: look, edit if you like, then post what you already paid for.
4439    pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
4440        let path = self.pending_comment_path(number);
4441        if let Some(parent) = path.parent() {
4442            std::fs::create_dir_all(parent)
4443                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
4444        }
4445        std::fs::write(&path, text)
4446            .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4447        Ok(path)
4448    }
4449
4450    pub fn read_pending_comment(&self, number: i64) -> Option<String> {
4451        std::fs::read_to_string(self.pending_comment_path(number)).ok()
4452    }
4453
4454    pub fn state_path(&self, number: i64) -> PathBuf {
4455        self.root
4456            .join(STATE_DIR)
4457            .join("state")
4458            .join(format!("pr-{number}.json"))
4459    }
4460
4461    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
4462        let path = self.state_path(number);
4463        let text = std::fs::read_to_string(&path).ok()?;
4464        match serde_json::from_str(&text) {
4465            Ok(state) => Some(state),
4466            Err(_) => {
4467                logdim!("could not read {}, starting fresh", path.display());
4468                None
4469            }
4470        }
4471    }
4472
4473    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
4474        if let Some(local) = self.read_local_state(pr.number) {
4475            return Some(local);
4476        }
4477        if self.state_store.writes_pr() {
4478            return self.read_pr_state(pr.number);
4479        }
4480        None
4481    }
4482
4483    pub(crate) fn read_state_for_head(
4484        &self,
4485        pr: &PrView,
4486        actual_head: &str,
4487    ) -> Option<PersistedState> {
4488        let local = self
4489            .state_store
4490            .writes_local()
4491            .then(|| self.read_local_state(pr.number))
4492            .flatten();
4493        let remote = self
4494            .state_store
4495            .writes_pr()
4496            .then(|| self.read_pr_state(pr.number))
4497            .flatten();
4498        let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
4499        if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
4500            self.remember_checkpoint(pr.number, checkpoint);
4501        }
4502        choose_state_for_head(candidates, actual_head)
4503    }
4504
4505    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
4506        self.try_read_pr_state(number).ok().flatten()
4507    }
4508
4509    fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
4510        for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
4511            if let Some(state) = parse_state_comment(&body) {
4512                return Ok(Some(state));
4513            }
4514        }
4515        Ok(None)
4516    }
4517
4518    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4519        let remote_state = if self.state_store.writes_pr() {
4520            self.try_read_pr_state(number)
4521        } else {
4522            Ok(None)
4523        };
4524        self.write_state_after_remote_read(number, state, remote_state)
4525    }
4526
4527    fn write_state_after_remote_read(
4528        &self,
4529        number: i64,
4530        state: &PersistedState,
4531        remote_state: Result<Option<PersistedState>>,
4532    ) -> Result<()> {
4533        let remote_checkpoint = if self.state_store.writes_pr() {
4534            self.record_failed_write(remote_state)?
4535                .map(|saved| saved.checkpoint)
4536                .unwrap_or_default()
4537        } else {
4538            0
4539        };
4540        let local_checkpoint = self
4541            .state_store
4542            .writes_local()
4543            .then(|| self.read_local_state(number))
4544            .flatten()
4545            .map(|saved| saved.checkpoint)
4546            .unwrap_or_default();
4547        let mut stamped = state.clone();
4548        stamped.checkpoint = state
4549            .checkpoint
4550            .max(local_checkpoint)
4551            .max(remote_checkpoint)
4552            .max(self.remembered_checkpoint(number))
4553            .saturating_add(1);
4554        self.remember_checkpoint(number, stamped.checkpoint);
4555        if self.state_store.writes_local() {
4556            write_json_atomic(&self.state_path(number), &stamped)?;
4557        }
4558        if self.state_store.writes_pr() {
4559            self.write_pr_state(number, &stamped)?;
4560        }
4561        Ok(())
4562    }
4563
4564    fn remembered_checkpoint(&self, number: i64) -> u64 {
4565        self.checkpoints
4566            .lock()
4567            .unwrap_or_else(std::sync::PoisonError::into_inner)
4568            .get(&number)
4569            .copied()
4570            .unwrap_or_default()
4571    }
4572
4573    fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
4574        let mut checkpoints = self
4575            .checkpoints
4576            .lock()
4577            .unwrap_or_else(std::sync::PoisonError::into_inner);
4578        let saved = checkpoints.entry(number).or_default();
4579        *saved = (*saved).max(checkpoint);
4580    }
4581
4582    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4583        // Not run through clean(): this is structured data, and scrubbing would
4584        // corrupt refutation text stored in the ledger. It sits inside an
4585        // unclosed HTML comment so GitHub renders it as nothing.
4586        let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
4587        let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
4588        let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
4589        if let Some(id) = comment_id {
4590            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4591            let field = format!("body={body}");
4592            let written = self
4593                .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
4594                .map(|_| ());
4595            return self.record_write(written);
4596        }
4597        let written = self
4598            .gh(&["pr", "comment", &number.to_string(), "--body", &body])
4599            .map(|_| ());
4600        self.record_write(written)
4601    }
4602
4603    /// Top level comments. Works for issues and pull requests alike, because
4604    /// GitHub serves both from the issues endpoint.
4605    ///
4606    /// Nothing when they cannot be read, which suits a reader that is going to
4607    /// go on regardless. A caller deciding whether it has already written here
4608    /// wants `try_issue_comments`, since for that one no comments and no answer
4609    /// are opposite answers.
4610    pub fn issue_comments(&self, number: i64) -> Vec<Value> {
4611        self.try_issue_comments(number).unwrap_or_default()
4612    }
4613
4614    pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
4615        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
4616        try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
4617    }
4618
4619    /// spar's own state comments on a pull request, newest last.
4620    ///
4621    /// Three tests, because the answer decides what gets patched over and what
4622    /// gets deleted. A comment matching the marker loosely, which is what
4623    /// somebody asking "why is there a `<!-- spar:state` block on this PR?"
4624    /// writes, is named in the log and left exactly where it is.
4625    fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
4626        let viewer = self.viewer_login()?.to_string();
4627        let mut mine = Vec::new();
4628        let mut theirs = Vec::new();
4629        for comment in self.try_issue_comments(number)? {
4630            let Some(body) = comment.get("body").and_then(Value::as_str) else {
4631                continue;
4632            };
4633            if !body.contains("spar:state") {
4634                continue;
4635            }
4636            let Some(id) = comment.get("id").and_then(Value::as_i64) else {
4637                continue;
4638            };
4639            let author = comment
4640                .get("user")
4641                .and_then(|u| u.get("login"))
4642                .and_then(Value::as_str)
4643                .unwrap_or_default();
4644            if is_state_comment(body, author, &viewer) {
4645                mine.push((id, body.to_string()));
4646            } else {
4647                theirs.push(format!("@{author}"));
4648            }
4649        }
4650        if !theirs.is_empty() {
4651            logdim!(
4652                "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
4653                 own state block is patched or deleted.",
4654                theirs.len(),
4655                theirs.join(", ")
4656            );
4657        }
4658        Ok(mine)
4659    }
4660
4661    fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
4662        Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
4663    }
4664
4665    /// Drop state once the PR is finished and there is nothing to resume.
4666    pub fn clear_state(&self, number: i64) {
4667        let path = self.state_path(number);
4668        let _ = std::fs::remove_file(&path);
4669        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
4670    }
4671
4672    // -- housekeeping -----------------------------------------------------
4673
4674    /// Remove state files whose PR is merged or closed.
4675    pub fn prune_state(&self) -> Vec<String> {
4676        let base = self.root.join(STATE_DIR).join("state");
4677        let Ok(entries) = std::fs::read_dir(&base) else {
4678            return Vec::new();
4679        };
4680        let mut names: Vec<String> = entries
4681            .flatten()
4682            .filter_map(|e| e.file_name().to_str().map(str::to_string))
4683            .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
4684            .collect();
4685        names.sort();
4686
4687        let mut removed = Vec::new();
4688        for name in names {
4689            let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
4690                continue;
4691            };
4692            if is_finished(&self.pr_state(number)) {
4693                let _ = std::fs::remove_file(base.join(&name));
4694                removed.push(format!("state {name}"));
4695            }
4696        }
4697        removed
4698    }
4699
4700    /// Delete state comments from PRs that are finished.
4701    ///
4702    /// Open PRs are left alone: their state may still be live.
4703    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
4704        #[derive(Deserialize)]
4705        struct Row {
4706            number: i64,
4707        }
4708        let numbers = match numbers {
4709            Some(numbers) => numbers,
4710            None => {
4711                let listed: Result<Vec<i64>> = (|| {
4712                    let text = self.gh(&[
4713                        "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
4714                    ])?;
4715                    let rows = serde_json::from_str::<Vec<Row>>(text.trim())
4716                        .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
4717                    Ok(rows.into_iter().map(|row| row.number).collect())
4718                })();
4719                match self.record_failed_write(listed) {
4720                    Ok(numbers) => numbers,
4721                    Err(e) => {
4722                        logdim!("could not inspect pull requests for state cleanup: {e}");
4723                        return Vec::new();
4724                    }
4725                }
4726            }
4727        };
4728
4729        let mut removed = Vec::new();
4730        for number in numbers {
4731            let state = match self.record_failed_write(self.try_pr_state(number)) {
4732                Ok(state) => state,
4733                Err(e) => {
4734                    logdim!("could not inspect PR #{number} for state cleanup: {e}");
4735                    continue;
4736                }
4737            };
4738            if !is_finished(&state) {
4739                continue;
4740            }
4741            let comments = match self.record_failed_write(self.try_state_comments(number)) {
4742                Ok(comments) => comments,
4743                Err(e) => {
4744                    logdim!("could not inspect state comments on PR #{number}: {e}");
4745                    continue;
4746                }
4747            };
4748            for (id, _) in comments {
4749                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4750                let deleted = self
4751                    .gh(&["api", "-X", "DELETE", &path, "--silent"])
4752                    .map(|_| ());
4753                match self.record_write(deleted) {
4754                    Ok(()) => removed.push(format!("state comment on PR #{number}")),
4755                    Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
4756                }
4757            }
4758        }
4759        removed
4760    }
4761
4762    /// Drop worktrees whose PR is finished, then the branches they left behind.
4763    ///
4764    /// With auto_merge off, which is the default, a run ends at "approved", so
4765    /// nothing would ever clean these up on its own and they accumulate one per
4766    /// run. A stranded worktree also holds its branch checked out, which makes
4767    /// a later `gh pr merge --delete-branch` fail to clean up.
4768    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
4769        let base = self.root.join(WORKTREE_DIR);
4770        let mut removed = Vec::new();
4771        let known = self.known_branches();
4772
4773        if let Ok(entries) = std::fs::read_dir(&base) {
4774            let mut names: Vec<String> = entries
4775                .flatten()
4776                .filter(|e| e.path().is_dir())
4777                .filter_map(|e| e.file_name().to_str().map(str::to_string))
4778                .collect();
4779            names.sort();
4780
4781            for name in names {
4782                // A review worktree is detached and owns no branch, so it is
4783                // tied to the pull request only by its directory name.
4784                if let Some(rest) = name.strip_prefix("review-") {
4785                    let number: i64 = rest.parse().unwrap_or(-1);
4786                    if !(force_all || is_finished(&self.pr_state(number))) {
4787                        continue;
4788                    }
4789                    let path = base.join(&name);
4790                    if force_all {
4791                        let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
4792                            if !belongs {
4793                                return Ok(false);
4794                            }
4795                            let local_ref = review_ref(number);
4796                            if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
4797                                return Ok(false);
4798                            }
4799                            let head = self.head_oid_checked(&path)?;
4800                            let recorded = self
4801                                .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
4802                                .trim()
4803                                .to_string();
4804                            Ok(head == recorded)
4805                        });
4806                        match owned {
4807                            Ok(true) => {}
4808                            Ok(false) => {
4809                                logdim!(
4810                                    "kept {} because no matching SPAR review reference proves \
4811                                     ownership",
4812                                    path.display()
4813                                );
4814                                continue;
4815                            }
4816                            Err(e) => {
4817                                logdim!(
4818                                    "kept {} because review ownership could not be verified: {}",
4819                                    path.display(),
4820                                    e.last_line()
4821                                );
4822                                continue;
4823                            }
4824                        }
4825                    } else {
4826                        if let Err(e) = self.refuse_review_worktree_changes(number) {
4827                            logdim!(
4828                                "kept {} because its review state could not be verified as \
4829                                 disposable: {}",
4830                                path.display(),
4831                                e.last_line()
4832                            );
4833                            continue;
4834                        }
4835                    }
4836                    if force_all {
4837                        if self.remove_worktree_at_force(&path) {
4838                            self.git_try(&["update-ref", "-d", &review_ref(number)]);
4839                        }
4840                    } else {
4841                        self.release_review_worktree(number);
4842                    }
4843                    if !path.exists() {
4844                        removed.push(name);
4845                    }
4846                    continue;
4847                }
4848                let branch = format!("{}{name}", self.branch_prefix);
4849                if !(force_all || self.worktree_is_done(&branch)) {
4850                    continue;
4851                }
4852                if !known.contains_key(&branch) {
4853                    logdim!("kept {branch} because it has no branch record");
4854                    continue;
4855                }
4856                let path = base.join(&name);
4857                if !force_all {
4858                    match self.has_recoverable_work(&path, Disposable::BuildOutput) {
4859                        Ok(true) => {
4860                            logdim!(
4861                                "kept {} because it contains uncommitted changes or ignored files",
4862                                path.display()
4863                            );
4864                            continue;
4865                        }
4866                        Err(e) => {
4867                            logdim!(
4868                                "kept {} because its Git state could not be checked: {}",
4869                                path.display(),
4870                                e.last_line()
4871                            );
4872                            continue;
4873                        }
4874                        Ok(false) => {}
4875                    }
4876                    match self.branch_deletion_is_safe(&branch) {
4877                        Ok(true) => {}
4878                        Ok(false) => {
4879                            logdim!(
4880                                "kept {branch} because no surviving ref preserves its tip or \
4881                                 reflog-only commits"
4882                            );
4883                            continue;
4884                        }
4885                        Err(e) => {
4886                            logdim!(
4887                                "kept {branch} because preservation could not be verified: {}",
4888                                e.last_line()
4889                            );
4890                            continue;
4891                        }
4892                    }
4893                }
4894                let removed_worktree = if force_all {
4895                    self.remove_worktree_at_force(&path)
4896                } else {
4897                    match self.remove_worktree_at(&path) {
4898                        Ok(removed) => removed,
4899                        Err(error) => {
4900                            logdim!(
4901                                "kept {branch} and {} because removal did not reach a confirmed \
4902                                 quiet point: {}",
4903                                path.display(),
4904                                error.last_line()
4905                            );
4906                            false
4907                        }
4908                    }
4909                };
4910                if !removed_worktree {
4911                    continue;
4912                }
4913                if force_all {
4914                    self.git_try(&["branch", "-D", &branch]);
4915                    self.forget_branch(&branch);
4916                } else {
4917                    match self.delete_branch_if_safe(&branch) {
4918                        Ok(true) => self.forget_branch(&branch),
4919                        Ok(false) => logdim!(
4920                            "kept {branch} because its tip or reflog changed before deletion"
4921                        ),
4922                        Err(error) => logdim!(
4923                            "kept {branch} because deletion safety could not be rechecked: {}",
4924                            error.last_line()
4925                        ),
4926                    }
4927                }
4928                removed.push(name);
4929            }
4930        }
4931        removed.extend(self.prune_branches(force_all));
4932        removed
4933    }
4934
4935    /// Delete leftover branches spar created whose worktree is already gone.
4936    ///
4937    /// Deletion is driven by the ledger of branches spar actually created, not
4938    /// by a name pattern. Names default to `issue-N`, which is exactly what a
4939    /// person would call a branch themselves, so a name alone can never
4940    /// establish ownership. This is the data loss guard.
4941    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
4942        let known = self.known_branches();
4943        let branches: Vec<String> = known.keys().cloned().collect();
4944        if branches.is_empty() {
4945            return Vec::new();
4946        }
4947
4948        let checked_out: Vec<String> = self
4949            .git_try(&["worktree", "list", "--porcelain"])
4950            .lines()
4951            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
4952            .collect();
4953
4954        // %(refname:short) is ambiguous when a tag shares the branch name (it
4955        // yields "heads/..."), so take the full ref and strip it here.
4956        let existing: Vec<String> = self
4957            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
4958            .lines()
4959            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
4960            .collect();
4961
4962        let mut removed = Vec::new();
4963        for branch in branches {
4964            if !existing.contains(&branch) {
4965                self.forget_branch(&branch); // already gone, drop the record
4966                continue;
4967            }
4968            if checked_out.contains(&branch) {
4969                continue;
4970            }
4971            if !(force_all || self.worktree_is_done(&branch)) {
4972                continue;
4973            }
4974            if !force_all {
4975                let Some(_record) = known.get(&branch) else {
4976                    continue;
4977                };
4978                match self.branch_deletion_is_safe(&branch) {
4979                    Ok(true) => {}
4980                    Ok(false) => {
4981                        logdim!(
4982                            "kept {branch} because no surviving ref preserves its tip or \
4983                             reflog-only commits"
4984                        );
4985                        continue;
4986                    }
4987                    Err(e) => {
4988                        logdim!(
4989                            "kept {branch} because preservation could not be verified: {}",
4990                            e.last_line()
4991                        );
4992                        continue;
4993                    }
4994                }
4995            }
4996            let deleted = if force_all {
4997                self.git(&["branch", "-D", &branch]).map(|_| true)
4998            } else {
4999                self.delete_branch_if_safe(&branch)
5000            };
5001            match deleted {
5002                Ok(true) => {
5003                    self.forget_branch(&branch);
5004                    removed.push(format!("branch {branch}"));
5005                }
5006                Ok(false) => {
5007                    logdim!("kept {branch} because its tip or reflog changed before deletion");
5008                }
5009                Err(e) => {
5010                    // A branch that silently survives pruning looks like a spar
5011                    // bug, so the name and git's own reason have to be said.
5012                    logdim!("could not delete {branch}: {}", e.last_line());
5013                }
5014            }
5015        }
5016        removed
5017    }
5018
5019    /// True when the PR behind this branch is merged or closed.
5020    fn worktree_is_done(&self, branch: &str) -> bool {
5021        #[derive(Deserialize)]
5022        struct Row {
5023            state: String,
5024        }
5025        let entry = branch
5026            .strip_prefix(self.branch_prefix.as_str())
5027            .unwrap_or(branch);
5028        if let Some(rest) = entry.strip_prefix("pr-") {
5029            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
5030        }
5031        // A split part is the same shape as an issue branch: one branch, whose
5032        // pull requests say whether it is finished. Without it here, a part
5033        // branch is one nothing but `clean --all` would ever remove.
5034        if entry.starts_with("issue-") || entry.starts_with("split-") {
5035            let text = self.gh_try(&[
5036                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
5037            ]);
5038            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
5039            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
5040        }
5041        false
5042    }
5043}
5044
5045// ---------------------------------------------------------------------------
5046// Free helpers
5047// ---------------------------------------------------------------------------
5048
5049/// Take a read-only probe again before treating its failure as evidence.
5050///
5051/// Every probe is a Git invocation under a thirty second cap plus a walk over a
5052/// tree an agent may still be building in. A probe that fails proves nothing
5053/// about the worktree, and there is a real difference between "the tree moved"
5054/// and "the question could not be asked just then", which the caller turns into
5055/// the same run-ending error.
5056pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
5057    match probe() {
5058        Ok(value) => Ok(value),
5059        Err(first) => {
5060            logdim!("retrying a worktree probe after: {}", first.last_line());
5061            probe()
5062        }
5063    }
5064}
5065
5066/// Read attribute files without asking Git to inspect working-tree content.
5067///
5068/// A newly written attribute can select a clean or smudge filter. It must be
5069/// detected before a post-call status, diff, or add command has a chance to run
5070/// that filter in the parent process.
5071pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
5072    let root = std::fs::canonicalize(cwd)
5073        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5074    let mut files = BTreeMap::new();
5075    let mut visited = BTreeSet::new();
5076    collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
5077    Ok(AttributeState { files })
5078}
5079
5080fn collect_attribute_files(
5081    root: &Path,
5082    repository: &Path,
5083    prefix: &Path,
5084    visited: &mut BTreeSet<PathBuf>,
5085    files: &mut BTreeMap<PathBuf, [u8; 32]>,
5086) -> Result<()> {
5087    let canonical = std::fs::canonicalize(repository)
5088        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5089    if !visited.insert(canonical) {
5090        bail!("submodule recursion revisited {}", repository.display());
5091    }
5092    let entries = index_entries(repository)?;
5093    let mut paths: BTreeSet<PathBuf> = entries
5094        .iter()
5095        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5096        .map(|entry| entry.path.clone())
5097        .collect();
5098    // Ignored on purpose. A `.gitattributes` under build output can only select
5099    // filters for paths that are themselves ignored, and no ignored path is
5100    // ever staged or compared as tracked content, so it cannot reach a managed
5101    // commit. Capturing it meant `cargo package` or `npm ci` writing one into
5102    // its own output ended a read-only call outright.
5103    let untracked = run_git_bytes(
5104        repository,
5105        &[
5106            "ls-files",
5107            "--others",
5108            "--exclude-standard",
5109            "-z",
5110            "--",
5111            ".gitattributes",
5112            ":(glob)**/.gitattributes",
5113        ],
5114    )?;
5115    if !untracked.is_empty() && !untracked.ends_with(&[0]) {
5116        bail!(
5117            "git returned an unterminated attribute-file listing for {}",
5118            repository.display()
5119        );
5120    }
5121    for raw in untracked
5122        .split(|byte| *byte == 0)
5123        .filter(|record| !record.is_empty())
5124    {
5125        paths.insert(safe_git_path(raw, "attribute")?);
5126    }
5127    for path in paths {
5128        let from_root = prefix.join(&path);
5129        let state = attribute_file_fingerprint(&root.join(&from_root))?;
5130        files.insert(from_root, state);
5131    }
5132    for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
5133        let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
5134            continue;
5135        };
5136        collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
5137    }
5138    Ok(())
5139}
5140
5141/// Leave a visible, untracked reason ordinary cleanup can detect on a later
5142/// run even when Git status normalizes the original working-file change away.
5143pub(crate) fn uncertain_worktree_change(
5144    cwd: &Path,
5145    message: impl Into<String>,
5146) -> crate::error::SparError {
5147    let message = message.into();
5148    let marker = write_recovery_marker(cwd, &message);
5149    let note = match marker {
5150        Ok(path) => format!(" Recovery marker: {}.", path.display()),
5151        Err(e) => format!(
5152            " A recovery marker could not be written: {}.",
5153            e.last_line()
5154        ),
5155    };
5156    crate::error::SparError::uncertain_write(format!("{message}{note}"))
5157}
5158
5159fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
5160    use std::sync::atomic::{AtomicU32, Ordering};
5161    static NEXT: AtomicU32 = AtomicU32::new(0);
5162    for _ in 0..1000 {
5163        let serial = NEXT.fetch_add(1, Ordering::Relaxed);
5164        let path = cwd.join(format!(
5165            "{RECOVERY_MARKER_PREFIX}{}-{serial}",
5166            std::process::id()
5167        ));
5168        let mut options = OpenOptions::new();
5169        options.write(true).create_new(true);
5170        #[cfg(unix)]
5171        {
5172            use std::os::unix::fs::OpenOptionsExt;
5173            options.mode(0o600);
5174        }
5175        match options.open(&path) {
5176            Ok(mut file) => {
5177                file.write_all(detail.as_bytes())
5178                    .and_then(|_| file.write_all(b"\n"))
5179                    .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5180                return Ok(path);
5181            }
5182            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
5183            Err(e) => {
5184                return Err(spar_err!(
5185                    "could not create a recovery marker in {}: {e}",
5186                    cwd.display()
5187                ))
5188            }
5189        }
5190    }
5191    bail!(
5192        "could not choose a free recovery marker name in {}",
5193        cwd.display()
5194    )
5195}
5196
5197/// Build a Git command that cannot launch automatic repository maintenance.
5198///
5199/// A fetch may otherwise prune missing linked worktree registrations. SPAR
5200/// must only remove registrations it has proven it owns.
5201fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
5202    let mut argv = vec![
5203        "git".to_string(),
5204        "-c".to_string(),
5205        "maintenance.auto=false".to_string(),
5206        "-c".to_string(),
5207        "gc.auto=0".to_string(),
5208    ];
5209    argv.extend(args.iter().map(|arg| (*arg).to_string()));
5210    argv
5211}
5212
5213fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
5214    let mut argv = git_without_maintenance_argv(&[]);
5215    argv.extend([
5216        "-c".to_string(),
5217        "core.fsmonitor=".to_string(),
5218        "-c".to_string(),
5219        "commit.gpgsign=false".to_string(),
5220        "-c".to_string(),
5221        "core.hooksPath=/dev/null".to_string(),
5222    ]);
5223    argv.extend(args.iter().map(|arg| (*arg).to_string()));
5224    argv
5225}
5226
5227/// Snapshot every untracked file, including ignored files, without changing
5228/// path bytes.
5229///
5230/// Without an exclude option, Git lists both ordinary and ignored untracked
5231/// entries. Metadata fingerprints make overwriting an existing path observable
5232/// without hashing a potentially multi-gigabyte build tree on every call.
5233pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
5234    let root = std::fs::canonicalize(cwd)
5235        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5236    let mut files = BTreeMap::new();
5237    let mut ignored = BTreeSet::new();
5238    let mut visited = BTreeSet::new();
5239    collect_untracked_files(
5240        &root,
5241        &root,
5242        Path::new(""),
5243        &mut visited,
5244        &mut files,
5245        &mut ignored,
5246    )?;
5247    Ok(IgnoredState { files, ignored })
5248}
5249
5250fn collect_untracked_files(
5251    root: &Path,
5252    repository: &Path,
5253    prefix: &Path,
5254    visited: &mut BTreeSet<PathBuf>,
5255    files: &mut BTreeMap<PathBuf, UntrackedFile>,
5256    ignored: &mut BTreeSet<PathBuf>,
5257) -> Result<()> {
5258    let canonical = std::fs::canonicalize(repository)
5259        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5260    if !visited.insert(canonical.clone()) {
5261        bail!("submodule recursion revisited {}", canonical.display());
5262    }
5263    let listed = run_git_bytes(repository, &["ls-files", "--others", "-z"])?;
5264    if !listed.is_empty() && !listed.ends_with(&[0]) {
5265        bail!(
5266            "git returned an unterminated untracked-file list for {}",
5267            repository.display()
5268        );
5269    }
5270
5271    for raw in listed
5272        .split(|byte| *byte == 0)
5273        .filter(|raw| !raw.is_empty())
5274    {
5275        let (relative, nested) = untracked_record(raw, "untracked")?;
5276        let from_root = prefix.join(&relative);
5277        if is_spar_own_path(&from_root) {
5278            continue;
5279        }
5280        let absolute = root.join(&from_root);
5281        let fingerprint = if nested {
5282            nested_repository_fingerprint(&absolute)?
5283        } else {
5284            ignored_file_fingerprint(&absolute)?
5285        };
5286        if files.insert(from_root.clone(), fingerprint).is_some() {
5287            bail!(
5288                "git returned the untracked path more than once: {:?}",
5289                from_root
5290            );
5291        }
5292    }
5293
5294    let ignored_listed = run_git_bytes(
5295        repository,
5296        &[
5297            "ls-files",
5298            "--others",
5299            "--ignored",
5300            "--exclude-standard",
5301            "-z",
5302        ],
5303    )?;
5304    if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
5305        bail!(
5306            "git returned an unterminated ignored-file list for {}",
5307            repository.display()
5308        );
5309    }
5310    // Two `ls-files` runs cannot be made to agree about a tree that is being
5311    // built in. A path named only by the second one was created between them,
5312    // which is a fact about the moment, not a corruption: record it and let the
5313    // comparison decide, rather than ending a run that had nothing wrong with
5314    // it.
5315    for raw in ignored_listed
5316        .split(|byte| *byte == 0)
5317        .filter(|raw| !raw.is_empty())
5318    {
5319        let (relative, nested) = untracked_record(raw, "ignored")?;
5320        let from_root = prefix.join(relative);
5321        if is_spar_own_path(&from_root) {
5322            continue;
5323        }
5324        if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
5325            let absolute = root.join(&from_root);
5326            let fingerprint = if nested {
5327                nested_repository_fingerprint(&absolute)?
5328            } else {
5329                ignored_file_fingerprint(&absolute)?
5330            };
5331            slot.insert(fingerprint);
5332        }
5333        if !ignored.insert(from_root.clone()) {
5334            bail!(
5335                "git returned the ignored path more than once: {:?}",
5336                from_root
5337            );
5338        }
5339    }
5340
5341    for link in gitlinks(repository)? {
5342        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
5343            continue;
5344        };
5345        collect_untracked_files(
5346            root,
5347            &submodule,
5348            &prefix.join(&link.path),
5349            visited,
5350            files,
5351            ignored,
5352        )?;
5353    }
5354    Ok(())
5355}
5356
5357fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
5358    let argv = git_without_automation_argv(args);
5359    proc::run_bytes(
5360        &argv,
5361        &ExecOpts::new()
5362            .cwd(cwd)
5363            .timeout_secs(30)
5364            .stop_descendants(true),
5365    )
5366}
5367
5368fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
5369    let argv = git_without_automation_argv(args);
5370    proc::run(
5371        &argv,
5372        &ExecOpts::new()
5373            .cwd(cwd)
5374            .timeout_secs(30)
5375            .stop_descendants(true),
5376    )
5377}
5378
5379fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
5380    let path = path.to_str().ok_or_else(|| {
5381        spar_err!(
5382            "cannot verify filtered content for a non-UTF-8 path in {}",
5383            cwd.display()
5384        )
5385    })?;
5386    let path_arg = format!("--path={path}");
5387    let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
5388    Ok(Sha256::digest(bytes).into())
5389}
5390
5391fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
5392    let relative = path_from_git_bytes(raw)?;
5393    if relative.is_absolute()
5394        || relative.components().any(|component| {
5395            matches!(
5396                component,
5397                std::path::Component::ParentDir
5398                    | std::path::Component::RootDir
5399                    | std::path::Component::Prefix(_)
5400            )
5401        })
5402    {
5403        bail!("git returned an unsafe {kind} path: {:?}", relative);
5404    }
5405    Ok(relative)
5406}
5407
5408/// Split one `ls-files --others` record into its path and whether Git reported
5409/// a nested repository rather than a single file.
5410///
5411/// Git never lists the contents of a repository inside the working tree, so a
5412/// checkout parked there, such as another of SPAR's own worktrees, arrives as
5413/// one record for the directory itself ending in a separator. Git writes that
5414/// separator on every platform. Trimming it keeps the recorded path equal to
5415/// the same path seen any other way.
5416fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
5417    let nested = raw.last() == Some(&b'/');
5418    let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
5419    if trimmed.is_empty() {
5420        bail!("git returned an empty {kind} path");
5421    }
5422    Ok((safe_git_path(trimmed, kind)?, nested))
5423}
5424
5425fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
5426    let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
5427    if !listed.is_empty() && !listed.ends_with(&[0]) {
5428        bail!(
5429            "git returned an unterminated index listing for {}",
5430            cwd.display()
5431        );
5432    }
5433    let mut entries = Vec::new();
5434    for record in listed
5435        .split(|byte| *byte == 0)
5436        .filter(|record| !record.is_empty())
5437    {
5438        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5439            bail!(
5440                "git returned a malformed index record for {}",
5441                cwd.display()
5442            );
5443        };
5444        let header = &record[..tab];
5445        let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
5446        if fields.len() != 3 {
5447            bail!(
5448                "git returned a malformed index header for {}",
5449                cwd.display()
5450            );
5451        }
5452        if fields[2] != b"0" {
5453            continue;
5454        }
5455        let mode = std::str::from_utf8(fields[0])
5456            .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
5457            .to_string();
5458        let oid = std::str::from_utf8(fields[1])
5459            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5460            .to_string();
5461        entries.push(IndexEntry {
5462            path: safe_git_path(&record[tab + 1..], "index")?,
5463            mode,
5464            oid,
5465        });
5466    }
5467    Ok(entries)
5468}
5469
5470fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
5471    let untracked = run_git_bytes(
5472        cwd,
5473        &[
5474            "ls-files",
5475            "--others",
5476            "-z",
5477            "--",
5478            ".gitattributes",
5479            ":(glob)**/.gitattributes",
5480        ],
5481    )?;
5482    if !untracked.is_empty() {
5483        return Ok(true);
5484    }
5485
5486    let index = index_entries(cwd)?
5487        .into_iter()
5488        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5489        .map(|entry| (entry.path, (entry.mode, entry.oid)))
5490        .collect::<BTreeMap<_, _>>();
5491    let head = tree_entries(cwd, "HEAD")?
5492        .into_iter()
5493        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5494        .map(|entry| (entry.path, (entry.mode, entry.oid)))
5495        .collect::<BTreeMap<_, _>>();
5496    if index != head {
5497        return Ok(true);
5498    }
5499
5500    let effective = check_attributes(cwd, index.keys().cloned())?;
5501    let config = CheckoutConfig::read(cwd)?;
5502    for (path, (_mode, oid)) in index {
5503        let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
5504            return Ok(true);
5505        };
5506        let attributes = effective
5507            .get(&path)
5508            .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
5509        if allows_expected_crlf(&config, attributes)? {
5510            if worktree.mode == "120000" {
5511                return Ok(true);
5512            }
5513            let (normalized, every_lf_was_crlf) =
5514                normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
5515            if !every_lf_was_crlf || normalized != oid {
5516                return Ok(true);
5517            }
5518        } else if worktree.raw_oid != oid {
5519            return Ok(true);
5520        }
5521    }
5522    Ok(false)
5523}
5524
5525fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
5526    Ok(index_entries(cwd)?
5527        .into_iter()
5528        .filter(|entry| entry.mode == "160000")
5529        .map(|entry| Gitlink {
5530            path: entry.path,
5531            oid: entry.oid,
5532        })
5533        .collect())
5534}
5535
5536fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
5537    let mut tracked = BTreeMap::new();
5538    for entry in index_entries(cwd)? {
5539        if entry.mode == "160000" {
5540            continue;
5541        }
5542        let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
5543        tracked.insert(
5544            entry.path,
5545            TrackedEntry {
5546                index_mode: entry.mode,
5547                index_oid: entry.oid,
5548                worktree,
5549            },
5550        );
5551    }
5552    Ok(tracked)
5553}
5554
5555fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
5556    let metadata = match std::fs::symlink_metadata(path) {
5557        Ok(metadata) => metadata,
5558        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
5559        Err(e) => {
5560            return Err(spar_err!(
5561                "could not inspect tracked file {}: {e}",
5562                path.display()
5563            ))
5564        }
5565    };
5566    let mut fingerprint = Sha256::new();
5567    if metadata.file_type().is_symlink() {
5568        let target = std::fs::read_link(path)
5569            .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
5570        let bytes = os_str_bytes(target.as_os_str())?;
5571        fingerprint.update(b"symlink\0");
5572        fingerprint.update(&bytes);
5573        let content = Sha256::digest(&bytes).into();
5574        return Ok(Some(WorktreeFile {
5575            mode: "120000".to_string(),
5576            #[cfg(unix)]
5577            permissions: 0,
5578            raw_oid: git_blob_oid(oid_len, &bytes)?,
5579            fingerprint: fingerprint.finalize().into(),
5580            content,
5581        }));
5582    }
5583    if !metadata.is_file() {
5584        bail!("tracked path {} is not a file or symlink", path.display());
5585    }
5586
5587    let mut options = OpenOptions::new();
5588    options.read(true);
5589    #[cfg(unix)]
5590    {
5591        use std::os::unix::fs::OpenOptionsExt;
5592        options.custom_flags(libc::O_NOFOLLOW);
5593    }
5594    let mut file = options
5595        .open(path)
5596        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5597    let before = file
5598        .metadata()
5599        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5600    let mode = tracked_file_mode(&before);
5601    #[cfg(unix)]
5602    let permissions = {
5603        use std::os::unix::fs::MetadataExt;
5604        before.mode() & 0o7777
5605    };
5606    fingerprint.update(b"file\0");
5607    fingerprint.update(mode.as_bytes());
5608    #[cfg(unix)]
5609    fingerprint.update(permissions.to_le_bytes());
5610    fingerprint.update(before.len().to_le_bytes());
5611    let mut content = Sha256::new();
5612    let header = format!("blob {}\0", before.len());
5613    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5614    let mut buf = [0u8; 64 * 1024];
5615    loop {
5616        let read = file
5617            .read(&mut buf)
5618            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5619        if read == 0 {
5620            break;
5621        }
5622        fingerprint.update(&buf[..read]);
5623        content.update(&buf[..read]);
5624        object.update(&buf[..read]);
5625    }
5626    let after = file
5627        .metadata()
5628        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5629    if before.len() != after.len()
5630        || before.modified().ok() != after.modified().ok()
5631        || before.permissions() != after.permissions()
5632    {
5633        bail!(
5634            "tracked file {} changed while it was being inspected",
5635            path.display()
5636        );
5637    }
5638    let current = std::fs::symlink_metadata(path)
5639        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5640    if !same_file(&after, &current) {
5641        bail!(
5642            "tracked file {} was replaced while it was being inspected",
5643            path.display()
5644        );
5645    }
5646    Ok(Some(WorktreeFile {
5647        mode,
5648        #[cfg(unix)]
5649        permissions,
5650        raw_oid: object.finish(),
5651        fingerprint: fingerprint.finalize().into(),
5652        content: content.finalize().into(),
5653    }))
5654}
5655
5656fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
5657    let metadata = std::fs::symlink_metadata(path)
5658        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5659    let mut digest = Sha256::new();
5660    if metadata.file_type().is_symlink() {
5661        digest.update(b"symlink\0");
5662        let target = std::fs::read_link(path)
5663            .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
5664        digest.update(os_str_bytes(target.as_os_str())?);
5665        return Ok(digest.finalize().into());
5666    }
5667    if !metadata.is_file() {
5668        bail!("attribute path {} is not a file or symlink", path.display());
5669    }
5670    let mut options = OpenOptions::new();
5671    options.read(true);
5672    #[cfg(unix)]
5673    {
5674        use std::os::unix::fs::OpenOptionsExt;
5675        options.custom_flags(libc::O_NOFOLLOW);
5676    }
5677    let mut file = options
5678        .open(path)
5679        .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5680    let before = file
5681        .metadata()
5682        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5683    digest.update(b"file\0");
5684    let mut buf = [0u8; 64 * 1024];
5685    loop {
5686        let read = file
5687            .read(&mut buf)
5688            .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5689        if read == 0 {
5690            break;
5691        }
5692        digest.update(&buf[..read]);
5693    }
5694    let after = file
5695        .metadata()
5696        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5697    let current = std::fs::symlink_metadata(path)
5698        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5699    if before.len() != after.len()
5700        || before.modified().ok() != after.modified().ok()
5701        || !same_file(&after, &current)
5702    {
5703        bail!(
5704            "attribute file {} changed while it was being inspected",
5705            path.display()
5706        );
5707    }
5708    Ok(digest.finalize().into())
5709}
5710
5711enum ObjectHasher {
5712    Sha1(Sha1),
5713    Sha256(Sha256),
5714}
5715
5716impl ObjectHasher {
5717    fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
5718        let mut hasher = match oid_len {
5719            40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
5720            64 => Self::Sha256(Sha256::new()),
5721            _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
5722        };
5723        hasher.update(header);
5724        Ok(hasher)
5725    }
5726
5727    fn update(&mut self, bytes: &[u8]) {
5728        match self {
5729            Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
5730            Self::Sha256(hasher) => hasher.update(bytes),
5731        }
5732    }
5733
5734    fn finish(self) -> String {
5735        let bytes = match self {
5736            Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
5737            Self::Sha256(hasher) => hasher.finalize().to_vec(),
5738        };
5739        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
5740    }
5741}
5742
5743fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
5744    let header = format!("blob {}\0", bytes.len());
5745    let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
5746    hasher.update(bytes);
5747    Ok(hasher.finish())
5748}
5749
5750fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
5751    let mut first = open_regular_file(path)?;
5752    let first_before = first
5753        .metadata()
5754        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5755    let mut raw_len = 0u64;
5756    let mut crlf_pairs = 0u64;
5757    let mut previous_was_cr = false;
5758    let mut every_lf_was_crlf = true;
5759    let mut buf = [0u8; 64 * 1024];
5760    loop {
5761        let read = first
5762            .read(&mut buf)
5763            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5764        if read == 0 {
5765            break;
5766        }
5767        raw_len = raw_len
5768            .checked_add(read as u64)
5769            .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
5770        for byte in &buf[..read] {
5771            if *byte == b'\n' {
5772                if previous_was_cr {
5773                    crlf_pairs += 1;
5774                } else {
5775                    every_lf_was_crlf = false;
5776                }
5777            }
5778            previous_was_cr = *byte == b'\r';
5779        }
5780    }
5781    let first_after = first
5782        .metadata()
5783        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5784    let current = std::fs::symlink_metadata(path)
5785        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5786    if raw_len != first_before.len()
5787        || !stable_file_metadata(&first_before, &first_after)
5788        || !stable_file_metadata(&first_after, &current)
5789    {
5790        bail!(
5791            "tracked file {} changed while line endings were inspected",
5792            path.display()
5793        );
5794    }
5795
5796    let normalized_len = raw_len
5797        .checked_sub(crlf_pairs)
5798        .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
5799    let header = format!("blob {normalized_len}\0");
5800    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5801    let mut second = open_regular_file(path)?;
5802    let second_before = second
5803        .metadata()
5804        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5805    if !stable_file_metadata(&first_after, &second_before) {
5806        bail!(
5807            "tracked file {} changed between line-ending checks",
5808            path.display()
5809        );
5810    }
5811    let mut pending_cr = false;
5812    loop {
5813        let read = second
5814            .read(&mut buf)
5815            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5816        if read == 0 {
5817            break;
5818        }
5819        for byte in &buf[..read] {
5820            if pending_cr {
5821                if *byte == b'\n' {
5822                    object.update(b"\n");
5823                    pending_cr = false;
5824                    continue;
5825                }
5826                object.update(b"\r");
5827                pending_cr = false;
5828            }
5829            if *byte == b'\r' {
5830                pending_cr = true;
5831            } else {
5832                object.update(std::slice::from_ref(byte));
5833            }
5834        }
5835    }
5836    if pending_cr {
5837        object.update(b"\r");
5838    }
5839    let second_after = second
5840        .metadata()
5841        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5842    let current = std::fs::symlink_metadata(path)
5843        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5844    if !stable_file_metadata(&second_before, &second_after)
5845        || !stable_file_metadata(&second_after, &current)
5846    {
5847        bail!(
5848            "tracked file {} changed while line endings were hashed",
5849            path.display()
5850        );
5851    }
5852    Ok((object.finish(), every_lf_was_crlf))
5853}
5854
5855fn open_regular_file(path: &Path) -> Result<std::fs::File> {
5856    let mut options = OpenOptions::new();
5857    options.read(true);
5858    #[cfg(unix)]
5859    {
5860        use std::os::unix::fs::OpenOptionsExt;
5861        options.custom_flags(libc::O_NOFOLLOW);
5862    }
5863    let file = options
5864        .open(path)
5865        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5866    let metadata = file
5867        .metadata()
5868        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5869    if !metadata.is_file() {
5870        bail!("tracked path {} is not a regular file", path.display());
5871    }
5872    Ok(file)
5873}
5874
5875fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
5876    if !same_file(left, right)
5877        || left.len() != right.len()
5878        || left.modified().ok() != right.modified().ok()
5879        || left.permissions() != right.permissions()
5880    {
5881        return false;
5882    }
5883    #[cfg(unix)]
5884    {
5885        use std::os::unix::fs::MetadataExt;
5886        left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
5887    }
5888    #[cfg(not(unix))]
5889    {
5890        left.created().ok() == right.created().ok()
5891    }
5892}
5893
5894/// How many paths one `check-attr` call is asked about.
5895///
5896/// `check-attr` answers with three NUL fields per attribute per path, so the
5897/// output is several times the input. A whole monorepo in one call is megabytes
5898/// in both directions, and the 30 second timeout below is a timeout on the
5899/// batch. Batching keeps each call small enough to be honest about.
5900const ATTRIBUTE_BATCH: usize = 1000;
5901
5902fn check_attributes(
5903    cwd: &Path,
5904    paths: impl IntoIterator<Item = PathBuf>,
5905) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5906    let paths = paths.into_iter().collect::<BTreeSet<_>>();
5907    if paths.is_empty() {
5908        return Ok(BTreeMap::new());
5909    }
5910    let ordered = paths.into_iter().collect::<Vec<_>>();
5911    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5912    for batch in ordered.chunks(ATTRIBUTE_BATCH) {
5913        values.extend(check_attribute_batch(cwd, batch)?);
5914    }
5915    Ok(values)
5916}
5917
5918fn check_attribute_batch(
5919    cwd: &Path,
5920    batch: &[PathBuf],
5921) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5922    const NAMES: [&str; 6] = [
5923        "filter",
5924        "working-tree-encoding",
5925        "ident",
5926        "text",
5927        "eol",
5928        "crlf",
5929    ];
5930    let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
5931    let mut input = String::new();
5932    for path in &paths {
5933        let path = path.to_str().ok_or_else(|| {
5934            spar_err!(
5935                "cannot inspect attributes for a non-UTF-8 path in {}",
5936                cwd.display()
5937            )
5938        })?;
5939        input.push_str(path);
5940        input.push('\0');
5941    }
5942    let argv = git_without_automation_argv(&[
5943        "check-attr",
5944        "-z",
5945        "--cached",
5946        "--stdin",
5947        "filter",
5948        "working-tree-encoding",
5949        "ident",
5950        "text",
5951        "eol",
5952        "crlf",
5953    ]);
5954    let output = proc::run_bytes(
5955        &argv,
5956        &ExecOpts::new()
5957            .cwd(cwd)
5958            .timeout_secs(30)
5959            .stdin(input)
5960            .stop_descendants(true),
5961    )?;
5962    if !output.is_empty() && !output.ends_with(&[0]) {
5963        bail!(
5964            "git returned an unterminated attribute result for {}",
5965            cwd.display()
5966        );
5967    }
5968    let fields = output
5969        .split(|byte| *byte == 0)
5970        .filter(|field| !field.is_empty())
5971        .collect::<Vec<_>>();
5972    if fields.len() != paths.len() * NAMES.len() * 3 {
5973        bail!(
5974            "git returned an unexpected attribute result for {}",
5975            cwd.display()
5976        );
5977    }
5978    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5979    for record in fields.chunks_exact(3) {
5980        let path = safe_git_path(record[0], "attribute")?;
5981        if !paths.contains(&path) {
5982            bail!(
5983                "git returned attributes for the wrong path in {}",
5984                cwd.display()
5985            );
5986        }
5987        let name = std::str::from_utf8(record[1])
5988            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
5989        let value = std::str::from_utf8(record[2])
5990            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
5991        values
5992            .entry(path)
5993            .or_default()
5994            .insert(name.to_string(), value.to_string());
5995    }
5996    if paths.iter().any(|path| {
5997        values
5998            .get(path)
5999            .is_none_or(|attributes| attributes.len() != NAMES.len())
6000    }) {
6001        bail!(
6002            "git omitted an attribute result for a tracked path in {}",
6003            cwd.display()
6004        );
6005    }
6006    Ok(values)
6007}
6008
6009fn attribute_is_active(value: Option<&String>) -> bool {
6010    !matches!(
6011        value.map(String::as_str),
6012        None | Some("unspecified") | Some("unset")
6013    )
6014}
6015
6016fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
6017    attribute_is_active(values.get("filter"))
6018        || attribute_is_active(values.get("working-tree-encoding"))
6019}
6020
6021/// The checkout settings a per-file line-ending decision depends on.
6022///
6023/// These are constant for a worktree, but the checks that read them run once
6024/// per tracked file, and each read was its own `git config` process. A
6025/// thousand-file repository with no `.gitattributes` takes every one of those
6026/// branches, which is half a minute of process spawning per scan, and the sweep
6027/// before a run scans every finished worktree more than once. Read them here,
6028/// once, and hand them down.
6029struct CheckoutConfig {
6030    autocrlf: Option<String>,
6031    eol: Option<String>,
6032    symlinks: Option<bool>,
6033}
6034
6035impl CheckoutConfig {
6036    fn read(cwd: &Path) -> Result<Self> {
6037        Ok(Self {
6038            autocrlf: git_config_value(cwd, "core.autocrlf")?,
6039            eol: git_config_value(cwd, "core.eol")?,
6040            symlinks: git_config_bool(cwd, "core.symlinks")?,
6041        })
6042    }
6043
6044    fn autocrlf_is_true(&self) -> bool {
6045        self.autocrlf.as_ref().is_some_and(|value| {
6046            matches!(
6047                value.to_ascii_lowercase().as_str(),
6048                "true" | "yes" | "on" | "1"
6049            )
6050        })
6051    }
6052
6053    fn eol_is(&self, wanted: &str) -> bool {
6054        self.eol
6055            .as_ref()
6056            .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
6057    }
6058}
6059
6060fn path_has_ambiguous_transform(
6061    config: &CheckoutConfig,
6062    values: &BTreeMap<String, String>,
6063) -> Result<bool> {
6064    if path_has_external_transform(values)
6065        || attribute_is_active(values.get("ident"))
6066        || attribute_is_active(values.get("crlf"))
6067    {
6068        return Ok(true);
6069    }
6070    let text = values.get("text").map(String::as_str);
6071    let eol = values.get("eol").map(String::as_str);
6072    if text == Some("auto") {
6073        return Ok(true);
6074    }
6075    if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
6076        || !matches!(
6077            eol,
6078            Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
6079        )
6080    {
6081        return Ok(true);
6082    }
6083    if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
6084        return Ok(config.autocrlf_is_true());
6085    }
6086    Ok(false)
6087}
6088
6089fn allows_expected_crlf(
6090    config: &CheckoutConfig,
6091    values: &BTreeMap<String, String>,
6092) -> Result<bool> {
6093    if path_has_external_transform(values)
6094        || attribute_is_active(values.get("ident"))
6095        || attribute_is_active(values.get("crlf"))
6096    {
6097        return Ok(false);
6098    }
6099    let text = values.get("text").map(String::as_str);
6100    let eol = values.get("eol").map(String::as_str);
6101    if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
6102        return Ok(false);
6103    }
6104    if eol == Some("crlf") {
6105        return Ok(true);
6106    }
6107    if text != Some("set") {
6108        return Ok(false);
6109    }
6110    if let Some(autocrlf) = config.autocrlf.as_deref() {
6111        match autocrlf.to_ascii_lowercase().as_str() {
6112            "true" | "yes" | "on" | "1" => return Ok(true),
6113            "input" => return Ok(false),
6114            _ => {}
6115        }
6116    }
6117    if config.eol_is("crlf") {
6118        return Ok(true);
6119    }
6120    #[cfg(windows)]
6121    if config.eol.is_none() || config.eol_is("native") {
6122        return Ok(true);
6123    }
6124    Ok(false)
6125}
6126
6127fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
6128    let argv = git_without_automation_argv(&["config", "--get", key]);
6129    let output = proc::exec(
6130        &argv,
6131        &ExecOpts::new()
6132            .cwd(cwd)
6133            .timeout_secs(30)
6134            .check(false)
6135            .stop_descendants(true),
6136    )?;
6137    match output.code {
6138        0 => Ok(Some(output.stdout.trim().to_string())),
6139        1 => Ok(None),
6140        _ => bail!(
6141            "could not read Git configuration in {}: {}",
6142            cwd.display(),
6143            output.stderr.trim()
6144        ),
6145    }
6146}
6147
6148fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
6149    let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
6150    let output = proc::exec(
6151        &argv,
6152        &ExecOpts::new()
6153            .cwd(cwd)
6154            .timeout_secs(30)
6155            .check(false)
6156            .stop_descendants(true),
6157    )?;
6158    match output.code {
6159        0 if output.stdout.trim() == "true" => Ok(Some(true)),
6160        0 if output.stdout.trim() == "false" => Ok(Some(false)),
6161        0 => bail!(
6162            "git returned an invalid boolean for {key} in {}",
6163            cwd.display()
6164        ),
6165        1 => Ok(None),
6166        _ => bail!(
6167            "could not read Git configuration in {}: {}",
6168            cwd.display(),
6169            output.stderr.trim()
6170        ),
6171    }
6172}
6173
6174#[cfg(unix)]
6175fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
6176    use std::os::unix::fs::PermissionsExt;
6177    if metadata.permissions().mode() & 0o111 == 0 {
6178        "100644".to_string()
6179    } else {
6180        "100755".to_string()
6181    }
6182}
6183
6184#[cfg(not(unix))]
6185fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
6186    "100644".to_string()
6187}
6188
6189fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
6190    let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
6191    if !listed.is_empty() && !listed.ends_with(&[0]) {
6192        bail!(
6193            "git returned an unterminated tree listing for {}",
6194            cwd.display()
6195        );
6196    }
6197    let mut entries = Vec::new();
6198    for record in listed
6199        .split(|byte| *byte == 0)
6200        .filter(|record| !record.is_empty())
6201    {
6202        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6203            bail!("git returned a malformed tree record for {}", cwd.display());
6204        };
6205        let fields = record[..tab]
6206            .split(|byte| *byte == b' ')
6207            .collect::<Vec<_>>();
6208        if fields.len() != 3 {
6209            bail!("git returned a malformed tree header for {}", cwd.display());
6210        }
6211        let mode = std::str::from_utf8(fields[0])
6212            .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
6213            .to_string();
6214        let oid = std::str::from_utf8(fields[2])
6215            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6216            .to_string();
6217        entries.push(IndexEntry {
6218            path: safe_git_path(&record[tab + 1..], "tree")?,
6219            mode,
6220            oid,
6221        });
6222    }
6223    Ok(entries)
6224}
6225
6226fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
6227    Ok(tree_entries(cwd, "HEAD")?
6228        .into_iter()
6229        .filter(|entry| entry.mode == "160000")
6230        .map(|entry| (entry.path, entry.oid))
6231        .collect())
6232}
6233
6234fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
6235    let head = head_gitlinks(cwd)?;
6236    let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
6237        .into_iter()
6238        .map(|link| (link.path, link.oid))
6239        .collect();
6240    let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
6241    paths.extend(index.keys().cloned());
6242    Ok(paths
6243        .into_iter()
6244        .filter(|path| head.get(path) != index.get(path))
6245        .collect())
6246}
6247
6248fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
6249    let path = parent.join(relative);
6250    let metadata = match std::fs::symlink_metadata(&path) {
6251        Ok(metadata) => metadata,
6252        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6253        Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6254    };
6255    if !metadata.is_dir() {
6256        bail!("the gitlink at {} is not a directory", path.display());
6257    }
6258    let canonical = std::fs::canonicalize(&path)
6259        .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
6260    if canonical != path {
6261        bail!(
6262            "the gitlink at {} resolves through a symlink",
6263            path.display()
6264        );
6265    }
6266    if !path.join(".git").exists() {
6267        let empty = std::fs::read_dir(&path)
6268            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
6269            .next()
6270            .is_none();
6271        if empty {
6272            return Ok(None);
6273        }
6274        bail!(
6275            "the uninitialized gitlink at {} contains local files",
6276            path.display()
6277        );
6278    }
6279    let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
6280    if inside.trim() != "true" {
6281        bail!("the gitlink at {} is not a worktree", path.display());
6282    }
6283    let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
6284    let top = std::fs::canonicalize(top.trim()).map_err(|e| {
6285        spar_err!(
6286            "could not resolve the gitlink top level at {}: {e}",
6287            path.display()
6288        )
6289    })?;
6290    if top != canonical {
6291        bail!(
6292            "the gitlink at {} belongs to a different worktree",
6293            path.display()
6294        );
6295    }
6296    Ok(Some(canonical))
6297}
6298
6299/// A Git entry the outer repository cannot account for.
6300///
6301/// A checkout Git itself reports as a nested repository is expected. Its
6302/// directory is recorded by identity in the untracked snapshot and keeps the
6303/// worktree from being released, so a reviewer that clones a reproduction into
6304/// ignored build output neither loses that clone nor fails the run over it.
6305/// The scan does not descend into one, because what is under it belongs to
6306/// that repository.
6307///
6308/// A Git entry Git does not report is the one worth stopping for. Running
6309/// `git init` inside a directory that already holds tracked files leaves
6310/// nothing in any listing while making every file written under it invisible
6311/// to the outer worktree, so SPAR can no longer prove what is there.
6312fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
6313    let root = std::fs::canonicalize(cwd)
6314        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6315    let mut allowed = BTreeSet::from([root.join(".git")]);
6316    let mut checkouts = BTreeSet::new();
6317    let mut repositories = vec![root.clone()];
6318    let mut visited = BTreeSet::new();
6319    while let Some(repository) = repositories.pop() {
6320        let canonical = std::fs::canonicalize(&repository)
6321            .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6322        if !visited.insert(canonical.clone()) {
6323            bail!("submodule recursion revisited {}", canonical.display());
6324        }
6325        for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
6326            let (path, nested) = untracked_record(&raw, "untracked")?;
6327            if nested {
6328                checkouts.insert(canonical.join(path));
6329            }
6330        }
6331        for link in gitlinks(&canonical)? {
6332            let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
6333                continue;
6334            };
6335            allowed.insert(submodule.join(".git"));
6336            repositories.push(submodule);
6337        }
6338    }
6339
6340    // A build deletes its own directories while this walks them. A directory
6341    // that is gone holds no Git entry, so skipping it answers the question
6342    // being asked; erroring instead ended runs whose worktrees were fine.
6343    let scan_root = root.clone();
6344    let mut directories = vec![root];
6345    while let Some(directory) = directories.pop() {
6346        let entries = match std::fs::read_dir(&directory) {
6347            Ok(entries) => entries,
6348            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6349            Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6350        };
6351        for entry in entries {
6352            let entry = match entry {
6353                Ok(entry) => entry,
6354                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6355                Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6356            };
6357            let path = entry.path();
6358            if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
6359                continue;
6360            }
6361            if entry.file_name() == OsStr::new(".git") {
6362                if !allowed.contains(&path) {
6363                    return Ok(Some(path));
6364                }
6365                continue;
6366            }
6367            let kind = match entry.file_type() {
6368                Ok(kind) => kind,
6369                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6370                Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6371            };
6372            if kind.is_dir() && !checkouts.contains(&path) {
6373                directories.push(path);
6374            }
6375        }
6376    }
6377    Ok(None)
6378}
6379
6380pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
6381    if let Some(path) = unexpected_nested_git_entry(cwd)? {
6382        bail!(
6383            "the worktree contains a Git entry at {} that Git does not report as a nested \
6384             checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
6385             prove they are unchanged.",
6386            path.display()
6387        );
6388    }
6389    let root = std::fs::canonicalize(cwd)
6390        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6391    let mut repositories = BTreeMap::new();
6392    let mut visited = BTreeSet::new();
6393    collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
6394    Ok(GitState { repositories })
6395}
6396
6397fn collect_git_state(
6398    repository: &Path,
6399    prefix: &Path,
6400    visited: &mut BTreeSet<PathBuf>,
6401    repositories: &mut BTreeMap<PathBuf, RepositoryState>,
6402) -> Result<()> {
6403    let canonical = std::fs::canonicalize(repository)
6404        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6405    if !visited.insert(canonical.clone()) {
6406        bail!("submodule recursion revisited {}", canonical.display());
6407    }
6408    let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6409    let head = head.trim().to_string();
6410    if head.is_empty() {
6411        bail!("git returned an empty head for {}", repository.display());
6412    }
6413    let unsafe_index_flags = unsafe_index_flags(repository)?;
6414    let tracked = tracked_entries(repository)?;
6415    let gitlinks = gitlinks(repository)?;
6416    if repositories
6417        .insert(
6418            prefix.to_path_buf(),
6419            RepositoryState {
6420                head,
6421                unsafe_index_flags,
6422                tracked,
6423                gitlinks: gitlinks
6424                    .iter()
6425                    .map(|link| (link.path.clone(), link.oid.clone()))
6426                    .collect(),
6427            },
6428        )
6429        .is_some()
6430    {
6431        bail!("Git state contains duplicate repository path {:?}", prefix);
6432    }
6433
6434    for link in gitlinks {
6435        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6436            continue;
6437        };
6438        collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
6439    }
6440    Ok(())
6441}
6442
6443fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
6444    let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
6445    if !listed.is_empty() && !listed.ends_with(&[0]) {
6446        bail!(
6447            "git returned an unterminated index-flag listing for {}",
6448            cwd.display()
6449        );
6450    }
6451    let mut unsafe_records = Vec::new();
6452    for record in listed
6453        .split(|byte| *byte == 0)
6454        .filter(|record| !record.is_empty())
6455    {
6456        if record.len() < 3 || record[1] != b' ' {
6457            bail!(
6458                "git returned a malformed index-flag record for {}",
6459                cwd.display()
6460            );
6461        }
6462        if record[0] != b'H' {
6463            unsafe_records.extend_from_slice(record);
6464            unsafe_records.push(0);
6465        }
6466    }
6467    Ok(unsafe_records)
6468}
6469
6470pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
6471    safe_git_state(cwd).map(|_| ())
6472}
6473
6474pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
6475    let state = git_state(cwd)?;
6476    if let Some((path, _repository)) = state
6477        .repositories
6478        .iter()
6479        .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
6480    {
6481        let label = if path.as_os_str().is_empty() {
6482            cwd.to_path_buf()
6483        } else {
6484            cwd.join(path)
6485        };
6486        bail!(
6487            "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
6488             SPAR cannot prove the working files are unchanged, so it was kept.",
6489            label.display()
6490        );
6491    }
6492    Ok(state)
6493}
6494
6495fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
6496    repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
6497}
6498
6499fn repository_has_recoverable_work_with(
6500    cwd: &Path,
6501    include_ignored: bool,
6502    disposable: Disposable,
6503) -> Result<bool> {
6504    if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
6505        return Ok(true);
6506    }
6507    let mut visited = BTreeSet::new();
6508    repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
6509}
6510
6511fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
6512    let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
6513    let git_dir = PathBuf::from(git_dir.trim());
6514    let git_dir = if git_dir.is_absolute() {
6515        git_dir
6516    } else {
6517        cwd.join(git_dir)
6518    };
6519    let git_dir = std::fs::canonicalize(&git_dir)
6520        .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
6521    match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
6522        Ok(_) => return Ok(true),
6523        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6524        Err(error) => {
6525            return Err(spar_err!(
6526                "could not inspect per-worktree configuration in {}: {error}",
6527                git_dir.display()
6528            ))
6529        }
6530    }
6531
6532    let orig_head = git_dir.join("ORIG_HEAD");
6533    match std::fs::symlink_metadata(&orig_head) {
6534        Ok(metadata) if metadata.is_file() => {
6535            let oid = std::fs::read_to_string(&orig_head)
6536                .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
6537            let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
6538                return Ok(true);
6539            };
6540            if !commit_has_shared_ref(cwd, &commit)? {
6541                return Ok(true);
6542            }
6543        }
6544        Ok(_) => return Ok(true),
6545        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6546        Err(error) => {
6547            return Err(spar_err!(
6548                "could not inspect {}: {error}",
6549                orig_head.display()
6550            ))
6551        }
6552    }
6553
6554    let edit_message = git_dir.join("COMMIT_EDITMSG");
6555    match std::fs::symlink_metadata(&edit_message) {
6556        Ok(metadata) if metadata.is_file() => {
6557            let draft = std::fs::read(&edit_message)
6558                .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
6559            if draft != head_commit_message(cwd)? {
6560                return Ok(true);
6561            }
6562        }
6563        Ok(_) => return Ok(true),
6564        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6565        Err(error) => {
6566            return Err(spar_err!(
6567                "could not inspect {}: {error}",
6568                edit_message.display()
6569            ))
6570        }
6571    }
6572
6573    if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
6574        return Ok(true);
6575    }
6576
6577    let local_refs = run_git_bytes(
6578        cwd,
6579        &[
6580            "for-each-ref",
6581            "--format=%(refname)",
6582            "refs/worktree",
6583            "refs/bisect",
6584            "refs/rewritten",
6585        ],
6586    )?;
6587    if !local_refs.is_empty() {
6588        return Ok(true);
6589    }
6590
6591    for entry in std::fs::read_dir(&git_dir)
6592        .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
6593    {
6594        let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
6595        let known = matches!(
6596            entry.file_name().to_str(),
6597            Some(
6598                "HEAD"
6599                    | "ORIG_HEAD"
6600                    | "COMMIT_EDITMSG"
6601                    | "commondir"
6602                    | "gitdir"
6603                    | "index"
6604                    | "logs"
6605                    | "refs"
6606            )
6607        );
6608        if !known {
6609            return Ok(true);
6610        }
6611    }
6612
6613    let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6614    if !commit_has_shared_ref(cwd, head.trim())? {
6615        return Ok(true);
6616    }
6617    Ok(false)
6618}
6619
6620fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
6621    let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
6622    let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
6623        bail!(
6624            "git returned a commit without a message separator in {}",
6625            cwd.display()
6626        );
6627    };
6628    Ok(commit[split + 2..].to_vec())
6629}
6630
6631fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
6632    let metadata = match std::fs::symlink_metadata(logs) {
6633        Ok(metadata) => metadata,
6634        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6635        Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
6636    };
6637    if !metadata.is_dir() {
6638        return Ok(true);
6639    }
6640    let mut files = Vec::new();
6641    let mut directories = vec![logs.to_path_buf()];
6642    while let Some(directory) = directories.pop() {
6643        for entry in std::fs::read_dir(&directory)
6644            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
6645        {
6646            let entry =
6647                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
6648            let path = entry.path();
6649            let kind = entry
6650                .file_type()
6651                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
6652            if kind.is_dir() {
6653                directories.push(path);
6654            } else if kind.is_file() {
6655                files.push(path);
6656            } else {
6657                return Ok(true);
6658            }
6659        }
6660    }
6661
6662    let mut commits = BTreeSet::new();
6663    for path in files {
6664        if !collect_reflog_commits(cwd, &path, &mut commits)? {
6665            return Ok(true);
6666        }
6667    }
6668    for commit in commits {
6669        if !commit_has_shared_ref(cwd, &commit)? {
6670            return Ok(true);
6671        }
6672    }
6673    Ok(false)
6674}
6675
6676/// Every commit named by one ref's common reflog must survive deletion of that
6677/// ref. Ancestors of a durable current tip survive with the tip; divergent
6678/// entries need another shared ref of their own.
6679fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
6680    let common = common_git_dir(cwd)?;
6681    let reflog = common.join("logs").join(refname);
6682    let metadata = match std::fs::symlink_metadata(&reflog) {
6683        Ok(metadata) => metadata,
6684        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
6685        Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
6686    };
6687    if !metadata.is_file() {
6688        return Ok(false);
6689    }
6690    let mut commits = BTreeSet::new();
6691    if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
6692        return Ok(false);
6693    }
6694    for commit in commits {
6695        if is_ancestor(cwd, &commit, durable_tip)?
6696            || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
6697        {
6698            continue;
6699        }
6700        return Ok(false);
6701    }
6702    Ok(true)
6703}
6704
6705fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
6706    let file = std::fs::File::open(path)
6707        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6708    for line in std::io::BufReader::new(file).lines() {
6709        let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6710        let mut fields = line.splitn(3, ' ');
6711        let Some(old) = fields.next() else {
6712            return Ok(false);
6713        };
6714        let Some(new) = fields.next() else {
6715            return Ok(false);
6716        };
6717        if fields.next().is_none() {
6718            return Ok(false);
6719        }
6720        for oid in [old, new] {
6721            if oid.bytes().all(|byte| byte == b'0') {
6722                continue;
6723            }
6724            let Some(commit) = resolve_optional_commit(cwd, oid)? else {
6725                return Ok(false);
6726            };
6727            commits.insert(commit);
6728        }
6729    }
6730    Ok(true)
6731}
6732
6733fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
6734    let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
6735    let path = PathBuf::from(raw.trim());
6736    let path = if path.is_absolute() {
6737        path
6738    } else {
6739        cwd.join(path)
6740    };
6741    std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
6742}
6743
6744fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
6745    let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
6746    let output = proc::exec(
6747        &argv,
6748        &ExecOpts::new()
6749            .cwd(cwd)
6750            .timeout_secs(30)
6751            .check(false)
6752            .stop_descendants(true),
6753    )?;
6754    match output.code {
6755        0 => Ok(true),
6756        1 => Ok(false),
6757        _ => bail!("{}", proc::failure_message(&argv, &output)),
6758    }
6759}
6760
6761fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
6762    let commit = format!("{oid}^{{commit}}");
6763    let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
6764    let output = proc::exec(
6765        &argv,
6766        &ExecOpts::new()
6767            .cwd(cwd)
6768            .timeout_secs(30)
6769            .check(false)
6770            .stop_descendants(true),
6771    )?;
6772    if output.code != 0 {
6773        return Ok(None);
6774    }
6775    let oid = output.stdout.trim();
6776    if oid.is_empty() {
6777        return Ok(None);
6778    }
6779    Ok(Some(oid.to_string()))
6780}
6781
6782fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
6783    commit_has_shared_ref_except(cwd, oid, None)
6784}
6785
6786fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
6787    let contains = format!("--contains={oid}");
6788    let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
6789    Ok(shared.split(|byte| *byte == b'\n').any(|record| {
6790        !record.is_empty()
6791            && !record.starts_with(b"refs/worktree/")
6792            && !record.starts_with(b"refs/bisect/")
6793            && !record.starts_with(b"refs/rewritten/")
6794            && exclude.is_none_or(|excluded| record != excluded.as_bytes())
6795    }))
6796}
6797
6798/// Whether removing the worktree would take away an untracked file somebody
6799/// might want back.
6800///
6801/// Ordinary untracked files always count. So does an ignored file outside the
6802/// known build and cache directories, because an ignored path is only a path
6803/// Git was told not to track, which is where a local `.env` lives as readily as
6804/// compiler output.
6805///
6806/// Recognized build and cache output does not. A managed commit already leaves
6807/// it out rather than treating it as work, and the command that wrote it writes
6808/// it again. Counting it kept every worktree whose tests or build had run,
6809/// which is nearly all of them, so a merged pull request still left its
6810/// checkout behind. A repository nested in that output is somebody else's
6811/// history and counts whatever it sits under.
6812fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
6813    let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
6814    for raw in &ordinary {
6815        let (path, _) = untracked_record(raw, "untracked")?;
6816        if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
6817            continue;
6818        }
6819        return Ok(true);
6820    }
6821    let listed = untracked_listing(
6822        cwd,
6823        &[
6824            "ls-files",
6825            "--others",
6826            "--ignored",
6827            "--exclude-standard",
6828            "-z",
6829        ],
6830    )?;
6831    for raw in listed {
6832        let (path, nested) = untracked_record(&raw, "ignored")?;
6833        // A repository cloned into the output keeps the checkout whatever the
6834        // mode: its objects exist nowhere else, which is the one thing a
6835        // rebuild from origin cannot replace.
6836        if nested {
6837            return Ok(true);
6838        }
6839        if disposable == Disposable::BuildOutput && !is_generated_artifact(&path) {
6840            return Ok(true);
6841        }
6842    }
6843    Ok(false)
6844}
6845
6846fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
6847    let listed = run_git_bytes(cwd, args)?;
6848    if !listed.is_empty() && !listed.ends_with(&[0]) {
6849        bail!(
6850            "git returned an unterminated untracked-file list for {}",
6851            cwd.display()
6852        );
6853    }
6854    Ok(listed
6855        .split(|byte| *byte == 0)
6856        .filter(|raw| !raw.is_empty())
6857        .map(|raw| raw.to_vec())
6858        .collect())
6859}
6860
6861fn repository_has_recoverable_work_inner(
6862    cwd: &Path,
6863    include_ignored: bool,
6864    disposable: Disposable,
6865    visited: &mut BTreeSet<PathBuf>,
6866) -> Result<bool> {
6867    let canonical = std::fs::canonicalize(cwd)
6868        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6869    if !visited.insert(canonical.clone()) {
6870        bail!("submodule recursion revisited {}", canonical.display());
6871    }
6872    if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
6873        return Ok(true);
6874    }
6875    if !unsafe_index_flags(cwd)?.is_empty() {
6876        return Ok(true);
6877    }
6878    if attributes_may_be_modified(cwd)? {
6879        return Ok(true);
6880    }
6881    if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
6882        return Ok(true);
6883    }
6884    if include_ignored {
6885        let index = index_entries(cwd)?
6886            .into_iter()
6887            .map(|entry| (entry.path, (entry.mode, entry.oid)))
6888            .collect::<BTreeMap<_, _>>();
6889        let head = tree_entries(cwd, "HEAD")?
6890            .into_iter()
6891            .map(|entry| (entry.path, (entry.mode, entry.oid)))
6892            .collect::<BTreeMap<_, _>>();
6893        if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
6894            return Ok(true);
6895        }
6896        let tracked = tracked_entries(cwd)?;
6897        let effective = check_attributes(cwd, tracked.keys().cloned())?;
6898        let config = CheckoutConfig::read(cwd)?;
6899        for (path, entry) in tracked {
6900            let Some(worktree) = entry.worktree else {
6901                return Ok(true);
6902            };
6903            let attributes = effective.get(&path).ok_or_else(|| {
6904                spar_err!("git omitted attributes for {}", cwd.join(&path).display())
6905            })?;
6906            if path_has_ambiguous_transform(&config, attributes)? {
6907                return Ok(true);
6908            }
6909            let symlink_file = entry.index_mode == "120000"
6910                && worktree.mode == "100644"
6911                && worktree.raw_oid == entry.index_oid
6912                && config.symlinks == Some(false);
6913            if worktree.mode != entry.index_mode && !symlink_file {
6914                return Ok(true);
6915            }
6916            if entry.index_mode == "120000" {
6917                if worktree.raw_oid != entry.index_oid {
6918                    return Ok(true);
6919                }
6920                continue;
6921            }
6922            #[cfg(unix)]
6923            {
6924                let expected = if entry.index_mode == "100755" {
6925                    0o755
6926                } else {
6927                    0o644
6928                };
6929                if worktree.permissions != expected {
6930                    return Ok(true);
6931                }
6932            }
6933            if allows_expected_crlf(&config, attributes)? {
6934                let (normalized, every_lf_was_crlf) =
6935                    normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
6936                if !every_lf_was_crlf || normalized != entry.index_oid {
6937                    return Ok(true);
6938                }
6939            } else if worktree.raw_oid != entry.index_oid {
6940                return Ok(true);
6941            }
6942        }
6943    } else {
6944        let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
6945        if !run_git_bytes(cwd, &args)?.is_empty() {
6946            return Ok(true);
6947        }
6948    }
6949    for link in gitlinks(cwd)? {
6950        let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
6951            continue;
6952        };
6953        // Git stores a linked worktree's initialized submodule objects under
6954        // that worktree's administrative directory. Ordinary removal cannot
6955        // prove a local submodule commit exists anywhere else, even when both
6956        // working trees look clean.
6957        if include_ignored {
6958            return Ok(true);
6959        }
6960        let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6961        if head.trim() != link.oid {
6962            return Ok(true);
6963        }
6964        if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
6965        {
6966            return Ok(true);
6967        }
6968    }
6969    Ok(false)
6970}
6971
6972pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
6973    repository_has_recoverable_work(cwd, false)
6974}
6975
6976fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
6977    let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
6978    Ok(!run_git_bytes(cwd, &args)?.is_empty())
6979}
6980
6981#[cfg(unix)]
6982fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6983    use std::os::unix::ffi::OsStringExt;
6984    Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
6985}
6986
6987#[cfg(not(unix))]
6988fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6989    String::from_utf8(raw.to_vec())
6990        .map(PathBuf::from)
6991        .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
6992}
6993
6994/// Fingerprint the directory of a nested repository without reading inside it.
6995///
6996/// The files under it are that repository's, not this one's. They are recorded
6997/// against its own baseline whenever SPAR works there, and a run of its own may
6998/// legitimately add or remove entries while this call is in flight, so the
6999/// volatile directory fields stay out of the fingerprint. Identity and type
7000/// remain, which is what makes deleting the checkout, or replacing it with a
7001/// file, observable from the outer worktree.
7002/// What a path Git listed looks like once it is no longer there.
7003///
7004/// A reviewer building the project deletes and rewrites its output constantly,
7005/// so a path can go between the listing that named it and the stat that
7006/// measures it. Treating that as an unreadable worktree ended runs that had
7007/// nothing wrong with them. The record stays in the snapshot rather than being
7008/// dropped, so a path that vanishes cannot pass for one that was never there.
7009fn vanished_untracked_file() -> UntrackedFile {
7010    UntrackedFile {
7011        kind: 0,
7012        len: 0,
7013        modified: None,
7014        created: None,
7015        readonly: false,
7016        symlink_target: None,
7017        #[cfg(unix)]
7018        device: 0,
7019        #[cfg(unix)]
7020        inode: 0,
7021        #[cfg(unix)]
7022        mode: 0,
7023        #[cfg(unix)]
7024        change_seconds: 0,
7025        #[cfg(unix)]
7026        change_nanoseconds: 0,
7027    }
7028}
7029
7030fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
7031    let metadata = match std::fs::symlink_metadata(path) {
7032        Ok(metadata) => metadata,
7033        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7034        Err(e) => {
7035            return Err(spar_err!(
7036                "could not inspect the nested repository at {}: {e}",
7037                path.display()
7038            ))
7039        }
7040    };
7041    if !metadata.is_dir() {
7042        bail!(
7043            "git reported {} as a nested repository, but it is not a directory",
7044            path.display()
7045        );
7046    }
7047    if std::fs::symlink_metadata(path.join(".git")).is_err() {
7048        bail!(
7049            "git reported {} as a nested repository, but it has no Git entry",
7050            path.display()
7051        );
7052    }
7053    #[cfg(unix)]
7054    {
7055        use std::os::unix::fs::MetadataExt;
7056        Ok(UntrackedFile {
7057            kind: 3,
7058            len: 0,
7059            modified: None,
7060            created: metadata.created().ok(),
7061            readonly: metadata.permissions().readonly(),
7062            symlink_target: None,
7063            device: metadata.dev(),
7064            inode: metadata.ino(),
7065            mode: metadata.mode(),
7066            change_seconds: 0,
7067            change_nanoseconds: 0,
7068        })
7069    }
7070    #[cfg(not(unix))]
7071    {
7072        Ok(UntrackedFile {
7073            kind: 3,
7074            len: 0,
7075            modified: None,
7076            created: metadata.created().ok(),
7077            readonly: metadata.permissions().readonly(),
7078            symlink_target: None,
7079        })
7080    }
7081}
7082
7083fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
7084    let metadata = match std::fs::symlink_metadata(path) {
7085        Ok(metadata) => metadata,
7086        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7087        Err(e) => {
7088            return Err(spar_err!(
7089                "could not inspect untracked file {}: {e}",
7090                path.display()
7091            ))
7092        }
7093    };
7094    let kind = if metadata.file_type().is_symlink() {
7095        2
7096    } else if metadata.is_file() {
7097        1
7098    } else {
7099        bail!(
7100            "untracked path {} is not a regular file or symlink",
7101            path.display()
7102        );
7103    };
7104    let symlink_target = if kind == 2 {
7105        match std::fs::read_link(path) {
7106            Ok(target) => Some(os_str_bytes(target.as_os_str())?),
7107            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7108                return Ok(vanished_untracked_file())
7109            }
7110            Err(e) => {
7111                return Err(spar_err!(
7112                    "could not read untracked symlink {}: {e}",
7113                    path.display()
7114                ))
7115            }
7116        }
7117    } else {
7118        None
7119    };
7120    #[cfg(unix)]
7121    {
7122        use std::os::unix::fs::MetadataExt;
7123        Ok(UntrackedFile {
7124            kind,
7125            len: metadata.len(),
7126            modified: metadata.modified().ok(),
7127            created: metadata.created().ok(),
7128            readonly: metadata.permissions().readonly(),
7129            symlink_target,
7130            device: metadata.dev(),
7131            inode: metadata.ino(),
7132            mode: metadata.mode(),
7133            change_seconds: metadata.ctime(),
7134            change_nanoseconds: metadata.ctime_nsec(),
7135        })
7136    }
7137    #[cfg(not(unix))]
7138    {
7139        Ok(UntrackedFile {
7140            kind,
7141            len: metadata.len(),
7142            modified: metadata.modified().ok(),
7143            created: metadata.created().ok(),
7144            readonly: metadata.permissions().readonly(),
7145            symlink_target,
7146        })
7147    }
7148}
7149
7150#[cfg(unix)]
7151fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7152    use std::os::unix::ffi::OsStrExt;
7153    Ok(value.as_bytes().to_vec())
7154}
7155
7156#[cfg(not(unix))]
7157fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7158    value
7159        .to_str()
7160        .map(|value| value.as_bytes().to_vec())
7161        .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
7162}
7163
7164#[cfg(unix)]
7165fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7166    use std::os::unix::fs::MetadataExt;
7167    right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
7168}
7169
7170#[cfg(not(unix))]
7171fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7172    right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
7173}
7174
7175#[derive(Debug, Clone, serde::Serialize, Deserialize)]
7176pub struct BranchRecord {
7177    pub kind: String,
7178    pub number: i64,
7179}
7180
7181/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
7182/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
7183pub fn review_ref(number: i64) -> String {
7184    format!("refs/spar/pr-{number}")
7185}
7186
7187pub fn is_finished(state: &str) -> bool {
7188    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
7189}
7190
7191/// Write text through a temporary file and rename, so a kill cannot leave a
7192/// truncated file behind.
7193///
7194/// The follow-up queue is the one file spar rewrites in place rather than
7195/// appends to, and a truncated queue is lost work: what it held was never
7196/// written anywhere else.
7197pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
7198    if let Some(parent) = path.parent() {
7199        std::fs::create_dir_all(parent)
7200            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
7201    }
7202    // The extension defaults to `json` so `clear_state`, which removes a
7203    // leftover `pr-N.json.tmp` by name, keeps finding the one this wrote.
7204    let tmp = path.with_extension(format!(
7205        "{}.tmp",
7206        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
7207    ));
7208    std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
7209    std::fs::rename(&tmp, path)
7210        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
7211    Ok(())
7212}
7213
7214/// Whether a follow-up file already has an entry under this exact title.
7215///
7216/// Whole heading lines, case insensitively. A substring check answered yes
7217/// for "Retry loop" against "## Retry loop never terminates", which is a
7218/// different defect, and the new one was dropped with a line saying it was
7219/// already noted.
7220pub fn has_heading(text: &str, title: &str) -> bool {
7221    let wanted = title.trim().to_lowercase();
7222    if wanted.is_empty() {
7223        return false;
7224    }
7225    text.lines()
7226        .filter_map(|line| line.trim().strip_prefix("## "))
7227        .any(|heading| heading.trim().to_lowercase() == wanted)
7228}
7229
7230/// Write JSON through a temporary file and rename, so a kill cannot leave a
7231/// truncated state file behind.
7232pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
7233    write_text_atomic(path, &serde_json::to_string_pretty(value)?)
7234}
7235
7236/// One row of `gh pr list --json number,url,title,baseRefName,closingIssuesReferences`.
7237#[derive(Deserialize)]
7238#[serde(rename_all = "camelCase")]
7239pub struct LinkedPrRow {
7240    number: i64,
7241    #[serde(default)]
7242    url: String,
7243    #[serde(default)]
7244    title: String,
7245    #[serde(default)]
7246    closing_issues_references: Vec<IssueRef>,
7247}
7248
7249/// The listed pull requests, or `None` when the payload could not be read.
7250///
7251/// Separated from the gh call so the real payload shape can be tested, and kept
7252/// distinct from an empty list, because silently failing to parse would look
7253/// exactly like "no pull request exists", which is the answer that makes spar
7254/// implement over the top of somebody's work.
7255pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
7256    serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
7257}
7258
7259/// Among the listed pull requests, the first that would close `issue`.
7260pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
7261    rows.iter()
7262        .find(|row| {
7263            row.closing_issues_references
7264                .iter()
7265                .any(|linked| linked.number == issue)
7266        })
7267        .map(|row| PrRef {
7268            number: row.number,
7269            url: row.url.clone(),
7270            title: row.title.clone(),
7271        })
7272}
7273
7274/// Both halves together, for the tests that hold the real payload.
7275pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
7276    pick_linked_pr(&linked_pr_rows(json)?, issue)
7277}
7278
7279/// Flatten whatever `gh api --paginate` printed into a list of comments.
7280///
7281/// Current gh merges array pages into one array. Older builds concatenated one
7282/// document per page. A streaming parser reads either, and unlike splitting the
7283/// text on a bracket pair it cannot be fooled by a comment body that happens to
7284/// contain one, which would otherwise make a resume silently start over.
7285fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
7286    if text.trim().is_empty() {
7287        return Err(spar_err!("GitHub returned no comment data"));
7288    }
7289    let mut out = Vec::new();
7290    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7291        match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
7292            Value::Array(items) => out.extend(items),
7293            _ => return Err(spar_err!("unexpected non-array comment page")),
7294        }
7295    }
7296    Ok(out)
7297}
7298
7299pub fn parse_comment_pages(text: &str) -> Vec<Value> {
7300    let mut out = Vec::new();
7301    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7302        match value {
7303            Ok(Value::Array(items)) => out.extend(items),
7304            Ok(other) => out.push(other),
7305            Err(_) => break,
7306        }
7307    }
7308    out
7309}
7310
7311/// Whether a comment is a state block spar itself wrote.
7312///
7313/// The body has to open with the marker, so a person quoting it in a sentence
7314/// is not one; it has to parse, so a half-quoted block is not one; and it has
7315/// to be the viewer's own comment, the way `comments.rs` already tells spar's
7316/// writes apart. Anything less and `write_pr_state` patches somebody's comment
7317/// with JSON, and `spar clean --pr-state` later deletes it.
7318pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
7319    body.trim_start().starts_with(STATE_MARKER)
7320        && !viewer.trim().is_empty()
7321        && author.trim().eq_ignore_ascii_case(viewer.trim())
7322        && parse_state_comment(body).is_some()
7323}
7324
7325/// Extract the payload from a state comment. The marker is followed by JSON and
7326/// terminated with `-->`.
7327pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
7328    let marker = body.find(STATE_MARKER)?;
7329    let start = body[marker..].find('{')? + marker;
7330    let end = body.rfind('}')?;
7331    if end <= start {
7332        return None;
7333    }
7334    match serde_json::from_str(&body[start..=end]) {
7335        Ok(state) => Some(state),
7336        Err(_) => {
7337            logdim!("found a spar state comment but could not parse it");
7338            None
7339        }
7340    }
7341}
7342
7343fn choose_state_for_head(
7344    candidates: Vec<PersistedState>,
7345    actual_head: &str,
7346) -> Option<PersistedState> {
7347    let matching: Vec<PersistedState> = candidates
7348        .iter()
7349        .filter(|state| state.pr_head == actual_head)
7350        .cloned()
7351        .collect();
7352    if !matching.is_empty() {
7353        return newest_state(matching);
7354    }
7355    newest_state(candidates)
7356}
7357
7358fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
7359    candidates.into_iter().reduce(|best, candidate| {
7360        if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
7361            candidate
7362        } else {
7363            // The local candidate is supplied first. Keeping the first exact
7364            // tie recovers correctly from a local write followed by a failed
7365            // pull request state update, including legacy states with no
7366            // checkpoint field.
7367            best
7368        }
7369    })
7370}
7371
7372/// Where this binary lives, so `git filter-branch` can call back into it.
7373///
7374/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
7375/// tests, whose `current_exe` is the test harness rather than spar, and for
7376/// anyone who ships spar behind a wrapper script.
7377pub fn self_binary() -> Result<PathBuf> {
7378    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
7379        let path = PathBuf::from(path);
7380        if proc::is_executable(&path) {
7381            return Ok(path);
7382        }
7383        bail!(
7384            "SPAR_SELF_BIN is set to {}, which is not executable",
7385            path.display()
7386        );
7387    }
7388    std::env::current_exe()
7389        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
7390}
7391
7392fn bool_env(value: bool) -> &'static str {
7393    if value {
7394        "1"
7395    } else {
7396        "0"
7397    }
7398}
7399
7400/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
7401/// shell command, and an install path with a space in it is not exotic.
7402pub fn sh_quote(text: &str) -> String {
7403    format!("'{}'", text.replace('\'', r"'\''"))
7404}
7405
7406/// Style rules for the `scrub-filter` subcommand, which runs in a child process
7407/// spawned by git and so cannot see the parent's config.
7408pub fn style_from_env() -> Style {
7409    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
7410    Style {
7411        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
7412        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
7413        ..Style::permissive()
7414    }
7415}
7416
7417#[cfg(test)]
7418mod tests {
7419    use super::*;
7420    use crate::config::StateStore;
7421    use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
7422    use std::process::Command;
7423
7424    fn repo_for_titles() -> Repo {
7425        Repo {
7426            root: PathBuf::from("/nonexistent"),
7427            style: Style::default(),
7428            branch_prefix: String::new(),
7429            state_store: StateStore::Local,
7430            followups: crate::config::Followups::Issues,
7431            drafts: Drafts::Never,
7432            viewer: OnceLock::new(),
7433            checkpoints: Mutex::new(BTreeMap::new()),
7434            writes: WriteStats::default(),
7435        }
7436    }
7437
7438    #[test]
7439    fn write_results_accumulate_for_the_run() {
7440        let repo = repo_for_titles();
7441
7442        let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
7443        let _: std::result::Result<(), ()> = repo.record_write(Err(()));
7444
7445        assert_eq!(
7446            WriteSummary {
7447                attempted: 2,
7448                failed: 1,
7449            },
7450            repo.write_summary()
7451        );
7452    }
7453
7454    #[test]
7455    fn only_failed_write_preflights_join_the_summary() {
7456        let repo = repo_for_titles();
7457
7458        let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
7459        let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
7460
7461        assert_eq!(
7462            WriteSummary {
7463                attempted: 1,
7464                failed: 1,
7465            },
7466            repo.write_summary()
7467        );
7468    }
7469
7470    #[test]
7471    fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
7472        let repo = repo_for_titles();
7473
7474        assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
7475        assert_eq!(
7476            WriteSummary {
7477                attempted: 1,
7478                failed: 1,
7479            },
7480            repo.write_summary()
7481        );
7482    }
7483
7484    #[test]
7485    fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
7486        let mut repo = repo_for_titles();
7487        repo.followups = Followups::Local;
7488
7489        assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
7490        assert_eq!(WriteSummary::default(), repo.write_summary());
7491    }
7492
7493    #[test]
7494    fn a_failed_remote_state_read_stops_before_state_mutation() {
7495        let root = std::env::temp_dir().join(format!(
7496            "spar-state-preflight-{}-{}",
7497            std::process::id(),
7498            std::time::SystemTime::now()
7499                .duration_since(std::time::UNIX_EPOCH)
7500                .unwrap()
7501                .as_nanos()
7502        ));
7503        std::fs::create_dir_all(&root).unwrap();
7504        let _fixture = ReviewFixture { root: root.clone() };
7505        let mut repo = repo_for_titles();
7506        repo.root = root;
7507        repo.state_store = StateStore::Both;
7508        let state = PersistedState {
7509            version: 1,
7510            checkpoint: 4,
7511            round: 2,
7512            next_actor: "a".into(),
7513            status: Status::Pending,
7514            pr_head: "abc123".into(),
7515            ledger: Ledger::new(),
7516            filed: Vec::new(),
7517            open_findings: Vec::new(),
7518            disputes: Vec::new(),
7519            noted: Vec::new(),
7520        };
7521
7522        let error = repo
7523            .write_state_after_remote_read(
7524                7,
7525                &state,
7526                Err(crate::error::SparError::new("state comments unavailable")),
7527            )
7528            .unwrap_err();
7529
7530        assert!(error.to_string().contains("state comments unavailable"));
7531        assert!(!repo.state_path(7).exists());
7532        assert_eq!(0, repo.remembered_checkpoint(7));
7533        assert_eq!(
7534            WriteSummary {
7535                attempted: 1,
7536                failed: 1,
7537            },
7538            repo.write_summary()
7539        );
7540    }
7541
7542    #[test]
7543    fn only_known_build_and_cache_directories_are_generated_artifacts() {
7544        assert!(is_generated_artifact(Path::new("target/debug/artifact")));
7545        assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
7546        assert!(is_generated_artifact(Path::new(
7547            "package/node_modules/dependency/file.js"
7548        )));
7549        assert!(!is_generated_artifact(Path::new(
7550            "distribution/required-package.js"
7551        )));
7552        assert!(!is_generated_artifact(Path::new(
7553            "generated/required-fixture.txt"
7554        )));
7555        assert!(!is_generated_artifact(Path::new("local.env")));
7556    }
7557
7558    struct ReviewFixture {
7559        root: PathBuf,
7560    }
7561
7562    impl Drop for ReviewFixture {
7563        fn drop(&mut self) {
7564            let _ = std::fs::remove_dir_all(&self.root);
7565        }
7566    }
7567
7568    fn test_git(cwd: &Path, args: &[&str]) -> String {
7569        let output = Command::new("git")
7570            .args(args)
7571            .current_dir(cwd)
7572            .output()
7573            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
7574        assert!(
7575            output.status.success(),
7576            "git {args:?} failed: {}",
7577            String::from_utf8_lossy(&output.stderr)
7578        );
7579        String::from_utf8_lossy(&output.stdout).into_owned()
7580    }
7581
7582    fn review_fixture(
7583        tag: &str,
7584        number: i64,
7585    ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
7586        use std::sync::atomic::{AtomicU32, Ordering};
7587        static NEXT: AtomicU32 = AtomicU32::new(0);
7588        let id = NEXT.fetch_add(1, Ordering::Relaxed);
7589        let root =
7590            std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
7591        let origin = root.join("origin.git");
7592        let work = root.join("work");
7593        std::fs::create_dir_all(&origin).unwrap();
7594        std::fs::create_dir_all(&work).unwrap();
7595        test_git(&origin, &["init", "--bare", "-b", "main"]);
7596        test_git(&work, &["init", "-b", "main"]);
7597        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
7598        test_git(&work, &["config", "user.name", "spar test"]);
7599        test_git(&work, &["config", "commit.gpgsign", "false"]);
7600        test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
7601        test_git(&work, &["config", "filter.drop.smudge", "cat"]);
7602        std::fs::write(work.join("README.md"), "seed\n").unwrap();
7603        std::fs::write(work.join("data.txt"), "old\n").unwrap();
7604        std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
7605        std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
7606        test_git(&work, &["add", "."]);
7607        test_git(&work, &["commit", "-m", "seed"]);
7608        test_git(
7609            &work,
7610            &["remote", "add", "origin", origin.to_str().unwrap()],
7611        );
7612        test_git(&work, &["push", "-u", "origin", "main"]);
7613        test_git(
7614            &work,
7615            &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
7616        );
7617        let cfg = crate::config::parse(
7618            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
7619        )
7620        .unwrap();
7621        let repo = Repo::open(&work, &cfg).unwrap();
7622        let path = repo.worktree_for_pr_head(number).unwrap();
7623        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7624        (ReviewFixture { root }, repo, path, checkpoint)
7625    }
7626
7627    #[test]
7628    fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
7629        let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
7630
7631        repo.release_review_worktree_checked(901, &checkpoint)
7632            .unwrap();
7633
7634        assert!(!path.exists());
7635    }
7636
7637    #[test]
7638    fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
7639        let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
7640        let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
7641        std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
7642        test_git(&path, &["add", "recovery.txt"]);
7643        test_git(&path, &["commit", "-m", "recovery commit"]);
7644        let recovery = test_git(&path, &["rev-parse", "HEAD"]);
7645        test_git(&path, &["reset", "--hard", "main"]);
7646
7647        assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
7648        test_git(
7649            &path,
7650            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7651        );
7652    }
7653
7654    #[test]
7655    fn a_review_ref_reflog_only_commit_prevents_deletion() {
7656        let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
7657        let local_ref = review_ref(921);
7658        let original = test_git(&path, &["rev-parse", &local_ref]);
7659        let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
7660        let recovery = test_git(
7661            &path,
7662            &[
7663                "commit-tree",
7664                tree.trim(),
7665                "-p",
7666                original.trim(),
7667                "-m",
7668                "review ref recovery",
7669            ],
7670        );
7671        test_git(
7672            &path,
7673            &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
7674        );
7675        test_git(
7676            &path,
7677            &["update-ref", &local_ref, original.trim(), recovery.trim()],
7678        );
7679
7680        assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
7681        assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
7682        test_git(
7683            &path,
7684            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7685        );
7686    }
7687
7688    #[test]
7689    fn an_unpublished_commit_message_draft_is_recoverable() {
7690        let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
7691        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
7692        let git_dir = if raw.is_absolute() {
7693            raw
7694        } else {
7695            path.join(raw)
7696        };
7697        std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
7698
7699        assert!(repository_has_recoverable_work(&path, true).unwrap());
7700        assert_eq!(
7701            "unique recovery draft\n",
7702            std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
7703        );
7704    }
7705
7706    #[test]
7707    fn a_changed_review_worktree_is_retained_after_a_checked_read() {
7708        let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
7709        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
7710
7711        let error = repo
7712            .release_review_worktree_checked(902, &checkpoint)
7713            .unwrap_err();
7714
7715        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7716        assert!(error.to_string().contains("kept for recovery"), "{error}");
7717        assert_eq!(
7718            "recover me\n",
7719            std::fs::read_to_string(path.join("README.md")).unwrap()
7720        );
7721        repo.release_review_worktree(902);
7722    }
7723
7724    #[test]
7725    fn a_review_commit_is_retained_after_a_checked_read() {
7726        let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
7727        std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
7728        test_git(&path, &["add", "review-note.txt"]);
7729        test_git(&path, &["commit", "-m", "local review recovery"]);
7730        let head = test_git(&path, &["rev-parse", "HEAD"]);
7731
7732        let error = repo
7733            .release_review_worktree_checked(903, &checkpoint)
7734            .unwrap_err();
7735
7736        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7737        assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
7738        assert_eq!(
7739            "recover me\n",
7740            std::fs::read_to_string(path.join("review-note.txt")).unwrap()
7741        );
7742        repo.release_review_worktree(903);
7743    }
7744
7745    /// The read is no longer what refuses: a file the call created and Git was
7746    /// told to ignore is its own output. Removal still refuses, so the file is
7747    /// there to look at, which is the part that mattered.
7748    #[test]
7749    fn an_ignored_review_file_is_retained_after_a_checked_read() {
7750        let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
7751        std::fs::create_dir_all(path.join("generated")).unwrap();
7752        std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
7753
7754        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7755            .expect("its own output is not a change");
7756        let error = repo
7757            .release_review_worktree_checked(904, &checkpoint)
7758            .unwrap_err();
7759
7760        assert!(error.to_string().contains("recoverable"), "{error}");
7761        assert_eq!(
7762            "recover me\n",
7763            std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
7764        );
7765        repo.release_review_worktree(904);
7766    }
7767
7768    #[test]
7769    /// The checkpoint no longer refuses this: a path the project ignores is out
7770    /// of scope for a call that only read. What keeps the file is the gate that
7771    /// actually deletes the checkout, which refuses any ignored file outside a
7772    /// known build directory. That is the check with the stake in it, and it is
7773    /// the one that has to hold.
7774    fn a_preexisting_ignored_review_file_change_is_retained() {
7775        let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
7776        std::fs::create_dir_all(path.join("generated")).unwrap();
7777        let ignored = path.join("generated/recovery.txt");
7778        std::fs::write(&ignored, "before\n").unwrap();
7779        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7780        std::fs::write(&ignored, "after!\n").unwrap();
7781
7782        let error = repo
7783            .release_review_worktree_checked(905, &checkpoint)
7784            .unwrap_err();
7785
7786        assert!(error.to_string().contains("recoverable"), "{error}");
7787        assert!(path.exists());
7788        assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
7789        repo.release_review_worktree(905);
7790    }
7791
7792    #[test]
7793    fn a_preexisting_ignored_review_file_prevents_checked_removal() {
7794        let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
7795        std::fs::create_dir_all(path.join("generated")).unwrap();
7796        let ignored = path.join("generated/recovery.txt");
7797        std::fs::write(&ignored, "keep me\n").unwrap();
7798        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7799
7800        let error = repo
7801            .release_review_worktree_checked(906, &checkpoint)
7802            .unwrap_err();
7803
7804        assert!(error.to_string().contains("recoverable"), "{error}");
7805        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7806    }
7807
7808    #[test]
7809    fn overwriting_a_preexisting_untracked_file_is_detected() {
7810        let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
7811        let untracked = path.join("notes.txt");
7812        std::fs::write(&untracked, "before\n").unwrap();
7813        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7814        std::fs::write(&untracked, "after!\n").unwrap();
7815
7816        let error = repo
7817            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7818            .unwrap_err();
7819
7820        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7821        assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
7822    }
7823
7824    #[test]
7825    fn an_assume_unchanged_edit_is_detected() {
7826        let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
7827        test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
7828        std::fs::write(path.join("README.md"), "hidden\n").unwrap();
7829
7830        let error = repo
7831            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7832            .unwrap_err();
7833
7834        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7835        assert_eq!(
7836            "hidden\n",
7837            std::fs::read_to_string(path.join("README.md")).unwrap()
7838        );
7839    }
7840
7841    #[test]
7842    fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
7843        let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
7844        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7845        test_git(&path, &["add", "README.md"]);
7846        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7847
7848        let error = repo
7849            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7850            .unwrap_err();
7851
7852        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7853        assert_eq!(
7854            b"seed\r\n",
7855            std::fs::read(path.join("README.md")).unwrap().as_slice()
7856        );
7857    }
7858
7859    #[cfg(unix)]
7860    #[test]
7861    fn a_mode_edit_is_detected_when_filemode_is_disabled() {
7862        use std::os::unix::fs::PermissionsExt;
7863
7864        let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
7865        test_git(&path, &["config", "core.filemode", "false"]);
7866        let readme = path.join("README.md");
7867        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7868        permissions.set_mode(0o755);
7869        std::fs::set_permissions(&readme, permissions).unwrap();
7870        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7871
7872        let error = repo
7873            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7874            .unwrap_err();
7875
7876        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7877        assert_eq!(
7878            0o755,
7879            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7880        );
7881    }
7882
7883    #[test]
7884    fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
7885        let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
7886        std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
7887        test_git(&path, &["add", ".gitattributes"]);
7888        test_git(&path, &["commit", "-m", "select data filter"]);
7889        let baseline = repo.worktree_baseline(&path).unwrap();
7890        std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
7891
7892        assert!(repo
7893            .commit_pending_changes(&path, &baseline, "change data", "change data")
7894            .unwrap());
7895        let error = repo
7896            .refuse_unrepresented_tracked_changes(&path, &baseline)
7897            .unwrap_err();
7898
7899        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7900        assert_eq!(
7901            "secret: recover me\nnew\n",
7902            std::fs::read_to_string(path.join("data.txt")).unwrap()
7903        );
7904        assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
7905    }
7906
7907    #[test]
7908    fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
7909        let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
7910        std::fs::create_dir_all(path.join("target")).unwrap();
7911        let untracked = path.join("target/user.yaml");
7912        std::fs::write(&untracked, "user data\n").unwrap();
7913        let baseline = repo.worktree_baseline(&path).unwrap();
7914        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7915
7916        assert!(repo
7917            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7918            .unwrap());
7919
7920        assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
7921        assert_eq!(
7922            "?? target/user.yaml\n",
7923            test_git(&path, &["status", "--short", "--untracked-files=all"])
7924        );
7925        assert!(test_git(
7926            &path,
7927            &[
7928                "ls-tree",
7929                "-r",
7930                "--name-only",
7931                "HEAD",
7932                "--",
7933                "target/user.yaml"
7934            ]
7935        )
7936        .is_empty());
7937    }
7938
7939    #[test]
7940    fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
7941        let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
7942        std::fs::create_dir_all(path.join("target")).unwrap();
7943        let untracked = path.join("target/user.yaml");
7944        std::fs::write(&untracked, "before\n").unwrap();
7945        let baseline = repo.worktree_baseline(&path).unwrap();
7946        let before = test_git(&path, &["rev-parse", "HEAD"]);
7947        std::fs::write(&untracked, "after\n").unwrap();
7948        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7949
7950        let error = repo
7951            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7952            .unwrap_err();
7953
7954        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7955        assert!(error.to_string().contains("target/user.yaml"), "{error}");
7956        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7957        assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
7958        assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
7959    }
7960
7961    #[test]
7962    fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
7963        let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
7964        let baseline = repo.worktree_baseline(&path).unwrap();
7965        std::fs::create_dir_all(path.join("target")).unwrap();
7966        std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
7967
7968        assert!(repo
7969            .commit_pending_changes(&path, &baseline, "add file", "add file")
7970            .unwrap());
7971
7972        assert_eq!(
7973            "new file\n",
7974            test_git(&path, &["show", "HEAD:target/new.txt"])
7975        );
7976        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7977    }
7978
7979    #[test]
7980    fn deleting_existing_ignored_work_stops_a_managed_commit() {
7981        let (_fixture, repo, path, _checkpoint) = review_fixture("deleted-ignored", 912);
7982        std::fs::create_dir_all(path.join("generated")).unwrap();
7983        let ignored = path.join("generated/keep.txt");
7984        std::fs::write(&ignored, "user data\n").unwrap();
7985        let baseline = repo.worktree_baseline(&path).unwrap();
7986        let before = test_git(&path, &["rev-parse", "HEAD"]);
7987        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7988        std::fs::remove_file(&ignored).unwrap();
7989
7990        let error = repo
7991            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7992            .unwrap_err();
7993
7994        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7995        assert!(error.to_string().contains("existing untracked"), "{error}");
7996        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7997        assert_eq!(
7998            "tracked change\n",
7999            std::fs::read_to_string(path.join("README.md")).unwrap()
8000        );
8001    }
8002
8003    #[test]
8004    fn new_ignored_work_stops_a_managed_commit_with_tracked_changes() {
8005        let (_fixture, repo, path, _checkpoint) = review_fixture("mixed-ignored", 926);
8006        let baseline = repo.worktree_baseline(&path).unwrap();
8007        let before = test_git(&path, &["rev-parse", "HEAD"]);
8008        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8009        std::fs::create_dir_all(path.join("generated")).unwrap();
8010        let ignored = path.join("generated/recovery.txt");
8011        std::fs::write(&ignored, "keep me\n").unwrap();
8012
8013        let error = repo
8014            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8015            .unwrap_err();
8016
8017        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8018        assert!(error.to_string().contains("recovery.txt"), "{error}");
8019        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
8020        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
8021        assert!(test_git(&path, &["status", "--porcelain"])
8022            .lines()
8023            .any(|line| line == "M  README.md"));
8024    }
8025
8026    #[test]
8027    fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
8028        let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
8029        test_git(&path, &["config", "core.autocrlf", "true"]);
8030        std::fs::write(path.join("README.md"), "seed\n").unwrap();
8031        assert_eq!(
8032            test_git(&path, &["hash-object", "README.md"]).trim(),
8033            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8034        );
8035
8036        assert!(repository_has_recoverable_work(&path, true).unwrap());
8037        assert_eq!(
8038            "seed\n",
8039            std::fs::read_to_string(path.join("README.md")).unwrap()
8040        );
8041    }
8042
8043    #[test]
8044    fn autocrlf_input_overrides_a_crlf_core_eol() {
8045        let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
8046        test_git(&path, &["config", "core.autocrlf", "input"]);
8047        test_git(&path, &["config", "core.eol", "crlf"]);
8048        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
8049        assert_eq!(
8050            test_git(&path, &["hash-object", "README.md"]).trim(),
8051            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8052        );
8053
8054        assert!(repository_has_recoverable_work(&path, true).unwrap());
8055        assert_eq!(
8056            b"seed\r\n",
8057            std::fs::read(path.join("README.md")).unwrap().as_slice()
8058        );
8059    }
8060
8061    #[cfg(unix)]
8062    #[test]
8063    fn a_non_executable_permission_change_is_recoverable() {
8064        use std::os::unix::fs::PermissionsExt;
8065
8066        let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
8067        let readme = path.join("README.md");
8068        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
8069        permissions.set_mode(0o600);
8070        std::fs::set_permissions(&readme, permissions).unwrap();
8071        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8072
8073        let error = repo
8074            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8075            .unwrap_err();
8076
8077        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8078        assert!(repository_has_recoverable_work(&path, true).unwrap());
8079        assert_eq!(
8080            0o600,
8081            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
8082        );
8083    }
8084
8085    #[cfg(unix)]
8086    #[test]
8087    fn a_managed_commit_skips_signing_and_hooks() {
8088        use std::os::unix::fs::PermissionsExt;
8089
8090        let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
8091        let common = common_git_dir(&path).unwrap();
8092        let hook = common.join("hooks/pre-commit");
8093        let marker = fixture.root.join("hook-ran");
8094        std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
8095        std::fs::write(
8096            &hook,
8097            format!(
8098                "#!/bin/sh\nprintf ran > {}\nexit 1\n",
8099                sh_quote(marker.to_str().unwrap())
8100            ),
8101        )
8102        .unwrap();
8103        let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
8104        permissions.set_mode(0o755);
8105        std::fs::set_permissions(&hook, permissions).unwrap();
8106        test_git(&path, &["config", "commit.gpgsign", "true"]);
8107        test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
8108        std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
8109        test_git(&path, &["add", "managed.txt"]);
8110
8111        repo.commit_staged_changes(&path, "record managed change")
8112            .unwrap();
8113
8114        assert!(!marker.exists());
8115        assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
8116    }
8117
8118    #[test]
8119    fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
8120        let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
8121        std::fs::write(
8122            path.join(".gitattributes"),
8123            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
8124        )
8125        .unwrap();
8126        test_git(&path, &["add", ".gitattributes"]);
8127        test_git(&path, &["commit", "-m", "select automatic text"]);
8128        test_git(&path, &["config", "core.autocrlf", "true"]);
8129
8130        assert!(repository_has_recoverable_work(&path, true).unwrap());
8131    }
8132
8133    #[test]
8134    fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
8135        let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
8136        std::fs::write(
8137            path.join(".gitattributes"),
8138            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
8139        )
8140        .unwrap();
8141        test_git(&path, &["add", ".gitattributes"]);
8142        test_git(&path, &["commit", "-m", "select ident expansion"]);
8143        std::fs::write(path.join("README.md"), "seed\n").unwrap();
8144
8145        assert!(repository_has_recoverable_work(&path, true).unwrap());
8146    }
8147
8148    /// Add ignore rules the way SPAR does, without a tracked change the
8149    /// worktree would then be kept for.
8150    /// The failure this whole rule was rewritten for: a reviewer ran the
8151    /// project's binding generator to check a finding, and the lockfile it left
8152    /// beside its own manifest matched no directory name spar knew.
8153    #[test]
8154    fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
8155        let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
8156        exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
8157        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8158        std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
8159        std::fs::write(
8160            path.join("bindings/uniffi-bindgen/Cargo.lock"),
8161            "# generated\n",
8162        )
8163        .unwrap();
8164
8165        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8166            .expect("a path the project ignores is the call's own output");
8167    }
8168
8169    #[test]
8170    fn an_attribute_file_under_build_output_is_not_a_filter_change() {
8171        let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
8172        exclude_paths(&repo, &["target/"]);
8173        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8174        std::fs::create_dir_all(path.join("target/package")).unwrap();
8175        std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
8176
8177        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8178            .expect("an ignored attribute file governs only ignored paths");
8179    }
8180
8181    /// Two reviewers share one checkout. The marker written when the first
8182    /// one's answer was refused used to be a brand new untracked file in the
8183    /// snapshot the second one was compared against.
8184    #[test]
8185    fn a_recovery_marker_is_not_a_change_to_the_worktree() {
8186        let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
8187        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8188        let _ = uncertain_worktree_change(&path, "an earlier call was refused");
8189
8190        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8191            .expect("spar's own marker is not something a call did");
8192    }
8193
8194    #[test]
8195    fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
8196        let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
8197
8198        let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
8199            .expect("a path a build removed is not an unreadable worktree");
8200
8201        assert_eq!(vanished_untracked_file(), gone);
8202    }
8203
8204    /// A path that goes is still a path that was there. Dropping it from the
8205    /// snapshot would let it pass for one the call never touched.
8206    #[test]
8207    fn a_vanished_path_is_not_recorded_as_absent() {
8208        let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
8209        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
8210        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8211        std::fs::remove_file(path.join("notes.txt")).unwrap();
8212
8213        let error = repo
8214            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8215            .unwrap_err();
8216
8217        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8218    }
8219
8220    fn exclude_paths(repo: &Repo, lines: &[&str]) {
8221        use std::io::Write;
8222        let path = repo.root().join(".git").join("info").join("exclude");
8223        let mut file = std::fs::OpenOptions::new()
8224            .create(true)
8225            .append(true)
8226            .open(&path)
8227            .unwrap();
8228        for line in lines {
8229            writeln!(file, "{line}").unwrap();
8230        }
8231    }
8232
8233    #[test]
8234    fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
8235        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
8236        exclude_paths(&repo, &["dist/"]);
8237        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8238        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8239        let baseline = repo.worktree_baseline(&path).unwrap();
8240        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8241        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8242        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8243        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8244
8245        assert!(repo
8246            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8247            .unwrap());
8248        assert_eq!(
8249            "tracked change\n",
8250            std::fs::read_to_string(path.join("README.md")).unwrap()
8251        );
8252    }
8253
8254    #[test]
8255    fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
8256        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
8257        exclude_paths(&repo, &["dist/"]);
8258        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8259        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8260        std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
8261        let baseline = repo.worktree_baseline(&path).unwrap();
8262        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8263        std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
8264        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8265        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8266
8267        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8268        repo.refuse_changed_existing_untracked(&path, &baseline)
8269            .unwrap();
8270    }
8271
8272    #[test]
8273    fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
8274        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
8275        exclude_paths(&repo, &["dist/"]);
8276        std::fs::create_dir_all(path.join("dist")).unwrap();
8277        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8278        let baseline = repo.worktree_baseline(&path).unwrap();
8279        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8280        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8281
8282        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8283        repo.refuse_changed_existing_untracked(&path, &baseline)
8284            .unwrap();
8285    }
8286
8287    /// The run this comes from: a reviewer rebuilt a project whose build output
8288    /// lands in `manager/public/assets/`, which no directory list will ever
8289    /// name. The guard inside the call kept the review. This check then called
8290    /// the same thirteen files unrepresentable and ended the round, five
8291    /// minutes of review thrown away over a path Git had been told to disown.
8292    #[test]
8293    fn a_read_only_call_may_rebuild_ignored_output_outside_the_directory_list() {
8294        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-rebuild", 943);
8295        exclude_paths(&repo, &["manager/public/assets/"]);
8296        std::fs::create_dir_all(path.join("manager/public/assets")).unwrap();
8297        let bundle = path.join("manager/public/assets/index-DPQ2vCfq.css");
8298        std::fs::write(&bundle, "first build\n").unwrap();
8299        let baseline = repo.worktree_baseline(&path).unwrap();
8300        std::fs::write(&bundle, "second build\n").unwrap();
8301        std::fs::write(path.join("manager/public/assets/index-ptLOWYCS.js"), "b\n").unwrap();
8302
8303        repo.refuse_read_only_leavings(&path, &baseline).unwrap();
8304        // The commit-side rule still refuses it, which is exactly why a call
8305        // that committed nothing must not be asked that question.
8306        assert!(repo.refuse_new_ignored_files(&path, &baseline).is_err());
8307    }
8308
8309    #[test]
8310    fn a_read_only_call_may_not_leave_an_ordinary_untracked_file() {
8311        let (_fixture, repo, path, _checkpoint) = review_fixture("read-only-scratch", 944);
8312        exclude_paths(&repo, &["dist/"]);
8313        let baseline = repo.worktree_baseline(&path).unwrap();
8314        std::fs::write(path.join("scratch.md"), "notes\n").unwrap();
8315
8316        let error = repo
8317            .refuse_read_only_leavings(&path, &baseline)
8318            .unwrap_err();
8319
8320        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8321        assert!(error.to_string().contains("scratch.md"), "{error}");
8322    }
8323
8324    #[test]
8325    fn a_new_ignored_file_outside_build_output_still_stops_a_call() {
8326        let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
8327        exclude_paths(&repo, &["dist/", ".env.local"]);
8328        std::fs::create_dir_all(path.join("dist")).unwrap();
8329        let baseline = repo.worktree_baseline(&path).unwrap();
8330        std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
8331        std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
8332
8333        let error = repo.refuse_new_ignored_files(&path, &baseline).unwrap_err();
8334
8335        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8336        assert!(error.to_string().contains(".env.local"), "{error}");
8337    }
8338
8339    #[test]
8340    fn a_read_only_inspection_may_rebuild_generated_output() {
8341        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
8342        exclude_paths(&repo, &["dist/"]);
8343        std::fs::create_dir_all(path.join("dist")).unwrap();
8344        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8345        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8346        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8347        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8348
8349        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8350            .unwrap();
8351    }
8352
8353    #[test]
8354    fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
8355        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
8356        exclude_paths(&repo, &["dist/", ".env.local"]);
8357        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
8358        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8359        std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
8360
8361        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8362            .unwrap();
8363    }
8364
8365    #[test]
8366    fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
8367        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
8368        exclude_paths(&repo, &["dist/", ".env.local"]);
8369        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
8370        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8371        std::fs::remove_file(path.join(".env.local")).unwrap();
8372
8373        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8374            .unwrap();
8375    }
8376
8377    #[test]
8378    fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
8379        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
8380        exclude_paths(&repo, &["dist/"]);
8381        std::fs::write(path.join("notes.md"), "before\n").unwrap();
8382        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8383        std::fs::write(path.join("notes.md"), "after\n").unwrap();
8384
8385        let error = repo
8386            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8387            .unwrap_err();
8388
8389        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8390        assert!(error.to_string().contains("notes.md"), "{error}");
8391    }
8392
8393    #[test]
8394    fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
8395        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
8396        exclude_paths(&repo, &["dist/", "local.env"]);
8397        std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
8398        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8399        // Stop ignoring it, so the same bytes are now an ordinary untracked
8400        // file. What a path is counts, not only whether it moved.
8401        std::fs::write(
8402            repo.root().join(".git").join("info").join("exclude"),
8403            "dist/\n",
8404        )
8405        .unwrap();
8406
8407        let error = repo
8408            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8409            .unwrap_err();
8410
8411        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8412        assert!(error.to_string().contains("local.env"), "{error}");
8413    }
8414
8415    #[test]
8416    fn a_read_only_inspection_names_the_tracked_file_that_moved() {
8417        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
8418        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8419        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
8420
8421        let error = repo
8422            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8423            .unwrap_err();
8424
8425        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8426        assert!(error.to_string().contains("README.md"), "{error}");
8427    }
8428
8429    #[test]
8430    fn build_output_alone_does_not_keep_a_worktree() {
8431        let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
8432        exclude_paths(&repo, &["target/", "dist/"]);
8433        std::fs::create_dir_all(path.join("target/debug")).unwrap();
8434        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8435        std::fs::create_dir_all(path.join("dist/cli")).unwrap();
8436        std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
8437
8438        assert!(!repository_has_recoverable_work(&path, true).unwrap());
8439        repo.release_review_worktree(933);
8440
8441        assert!(!path.exists());
8442    }
8443
8444    #[test]
8445    fn an_ignored_file_outside_build_output_keeps_a_worktree() {
8446        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
8447        exclude_paths(&repo, &["target/", ".env.local"]);
8448        std::fs::create_dir_all(path.join("target/debug")).unwrap();
8449        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8450        std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
8451
8452        assert!(repository_has_recoverable_work(&path, true).unwrap());
8453        repo.release_review_worktree(934);
8454
8455        assert_eq!(
8456            "TOKEN=keep me\n",
8457            std::fs::read_to_string(path.join(".env.local")).unwrap()
8458        );
8459    }
8460
8461    #[test]
8462    fn a_repository_nested_in_build_output_keeps_a_worktree() {
8463        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
8464        exclude_paths(&repo, &["node_modules/"]);
8465        let nested = path.join("node_modules/local-dep");
8466        std::fs::create_dir_all(&nested).unwrap();
8467        test_git(&nested, &["init"]);
8468        std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
8469
8470        assert!(repository_has_recoverable_work(&path, true).unwrap());
8471        repo.release_review_worktree(935);
8472
8473        assert!(nested.join(".git").exists());
8474    }
8475
8476    #[test]
8477    fn an_ordinary_untracked_file_keeps_a_worktree() {
8478        let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
8479        std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
8480
8481        assert!(repository_has_recoverable_work(&path, true).unwrap());
8482        repo.release_review_worktree(936);
8483
8484        assert_eq!(
8485            "somebody's notes\n",
8486            std::fs::read_to_string(path.join("notes.md")).unwrap()
8487        );
8488    }
8489
8490    #[test]
8491    fn a_legacy_crlf_checkout_is_retained_conservatively() {
8492        let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
8493        std::fs::write(
8494            path.join(".gitattributes"),
8495            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
8496        )
8497        .unwrap();
8498        test_git(&path, &["add", ".gitattributes"]);
8499        test_git(&path, &["commit", "-m", "select legacy line endings"]);
8500
8501        assert!(repository_has_recoverable_work(&path, true).unwrap());
8502    }
8503
8504    #[test]
8505    fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
8506        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
8507        let nested = path.join("tracked");
8508        std::fs::create_dir_all(&nested).unwrap();
8509        std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
8510        test_git(&path, &["add", "tracked/seed.txt"]);
8511        test_git(&path, &["commit", "-m", "add tracked directory"]);
8512        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8513        test_git(&nested, &["init"]);
8514
8515        let error = repo
8516            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8517            .unwrap_err();
8518
8519        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8520        assert!(error.to_string().contains("Git entry"), "{error}");
8521        assert!(nested.join(".git").exists());
8522    }
8523
8524    /// A reviewer that clones a reproduction into ignored build output used to
8525    /// lose the whole review. The state check refused every Git entry it had
8526    /// not put there, including the one Git reports as a nested checkout and
8527    /// the untracked snapshot already records by identity.
8528    #[test]
8529    fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
8530        let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
8531        exclude_paths(&repo, &["target/"]);
8532        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8533        let repro = path.join("target/pr918-repro");
8534        std::fs::create_dir_all(&repro).unwrap();
8535        test_git(&repro, &["init"]);
8536        std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
8537
8538        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8539            .unwrap();
8540
8541        assert!(repository_has_recoverable_work(&path, true).unwrap());
8542        assert!(repro.join(".git").exists());
8543    }
8544
8545    /// What sits under a nested checkout is that repository's business, so the
8546    /// scan stops at its directory rather than reading a checkout of its own
8547    /// as an entry the outer worktree has to account for.
8548    #[test]
8549    fn a_repository_inside_a_nested_checkout_is_left_alone() {
8550        let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
8551        exclude_paths(&repo, &["target/"]);
8552        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8553        let repro = path.join("target/repro");
8554        let vendored = repro.join("vendor/dep");
8555        std::fs::create_dir_all(&vendored).unwrap();
8556        test_git(&repro, &["init"]);
8557        test_git(&vendored, &["init"]);
8558
8559        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8560            .unwrap();
8561
8562        assert!(vendored.join(".git").exists());
8563    }
8564
8565    #[test]
8566    fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
8567        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
8568
8569        let state = ignored_untracked_state(repo.root()).unwrap();
8570
8571        let relative = path.strip_prefix(repo.root()).unwrap();
8572        assert!(
8573            state.files.contains_key(relative),
8574            "{:?}",
8575            state.files.keys().collect::<Vec<_>>()
8576        );
8577        assert!(state.is_ignored(relative));
8578    }
8579
8580    #[test]
8581    fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
8582        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
8583        let baseline = repo.worktree_baseline(repo.root()).unwrap();
8584        std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
8585        std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
8586
8587        repo.refuse_new_ignored_files(repo.root(), &baseline)
8588            .unwrap();
8589        repo.refuse_changed_existing_untracked(repo.root(), &baseline)
8590            .unwrap();
8591    }
8592
8593    #[test]
8594    fn deleting_a_resident_worktree_during_a_call_is_refused() {
8595        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
8596        let baseline = repo.worktree_baseline(repo.root()).unwrap();
8597        std::fs::remove_dir_all(&path).unwrap();
8598
8599        let error = repo
8600            .refuse_new_ignored_files(repo.root(), &baseline)
8601            .unwrap_err();
8602
8603        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8604        assert!(error.to_string().contains("review-932"), "{error}");
8605    }
8606
8607    #[test]
8608    fn a_nested_repository_record_is_read_as_a_plain_path() {
8609        let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
8610        assert_eq!(Path::new("vendor/checkout"), path);
8611        assert!(nested);
8612
8613        let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
8614        assert_eq!(Path::new("vendor/notes.txt"), path);
8615        assert!(!nested);
8616
8617        assert!(untracked_record(b"/", "untracked").is_err());
8618    }
8619
8620    #[cfg(unix)]
8621    #[test]
8622    fn a_non_utf8_git_path_is_preserved_without_loss() {
8623        use std::os::unix::ffi::OsStrExt;
8624
8625        let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
8626
8627        assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
8628    }
8629
8630    #[test]
8631    fn guarded_merge_pins_the_reviewed_head() {
8632        let args = merge_pr_args("36", Some("abc123"), true);
8633        assert_eq!(
8634            vec![
8635                "pr",
8636                "merge",
8637                "36",
8638                "--squash",
8639                "--delete-branch",
8640                "--match-head-commit",
8641                "abc123"
8642            ],
8643            args
8644        );
8645    }
8646
8647    #[test]
8648    fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
8649        let pr = PrRef {
8650            number: 7,
8651            url: "https://example.test/pull/7".into(),
8652            title: "part one".into(),
8653        };
8654        let result = reconcile_pr_creation(
8655            "split-34-1",
8656            Err(crate::error::SparError::new("connection lost")),
8657            Ok(Some(pr)),
8658        )
8659        .unwrap();
8660        assert_eq!(7, result.number);
8661    }
8662
8663    #[test]
8664    fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
8665        let error = reconcile_pr_creation(
8666            "split-34-1",
8667            Err(crate::error::SparError::new("permission denied")),
8668            Ok(None),
8669        )
8670        .unwrap_err();
8671        assert!(error.to_string().contains("permission denied"), "{error}");
8672    }
8673
8674    #[test]
8675    fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
8676        let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
8677        assert!(pr_for_base(text, "split-34-2", "split-34-1")
8678            .unwrap()
8679            .is_none());
8680        let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
8681        assert_eq!(7, found.number);
8682    }
8683
8684    #[test]
8685    fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
8686        let result = reconcile_comment_post(
8687            34,
8688            "the summary",
8689            crate::error::SparError::new("connection lost"),
8690            Ok(vec![serde_json::json!({"body": "the summary"})]),
8691        );
8692        assert!(result.is_ok(), "{result:?}");
8693    }
8694
8695    #[test]
8696    fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
8697        let error = reconcile_comment_post(
8698            34,
8699            "the summary",
8700            crate::error::SparError::new("connection lost"),
8701            Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
8702        )
8703        .unwrap_err();
8704        assert_eq!("connection lost", error.to_string());
8705    }
8706
8707    #[test]
8708    fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
8709        let error = reconcile_comment_post(
8710            34,
8711            "the summary",
8712            crate::error::SparError::new("connection lost"),
8713            Err(crate::error::SparError::new("comments unavailable")),
8714        )
8715        .unwrap_err();
8716        assert!(
8717            error.to_string().contains("could not be verified"),
8718            "{error}"
8719        );
8720        assert!(
8721            error.to_string().contains("comments unavailable"),
8722            "{error}"
8723        );
8724        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8725        assert!(!error.worth_retrying());
8726    }
8727
8728    #[test]
8729    fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
8730        let result = reconcile_issue_edit(
8731            34,
8732            "wanted body",
8733            crate::error::SparError::new("connection lost"),
8734            Ok("wanted body".to_string()),
8735        );
8736        assert!(result.is_ok(), "{result:?}");
8737    }
8738
8739    #[test]
8740    fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
8741        let error = reconcile_issue_edit(
8742            34,
8743            "wanted body",
8744            crate::error::SparError::new("connection lost"),
8745            Err(crate::error::SparError::new("issue unavailable")),
8746        )
8747        .unwrap_err();
8748        assert!(
8749            error.to_string().contains("could not be verified"),
8750            "{error}"
8751        );
8752        assert!(error.to_string().contains("issue unavailable"), "{error}");
8753        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8754        assert!(!error.worth_retrying());
8755    }
8756
8757    #[test]
8758    fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
8759        let found = ExistingIssue {
8760            number: 101,
8761            url: "https://example.test/issues/101".into(),
8762            title: "child".into(),
8763            body: "body".into(),
8764            open: true,
8765        };
8766        let url = reconcile_issue_creation(
8767            "child",
8768            Err(crate::error::SparError::new("connection lost")),
8769            Ok(Some(found)),
8770        )
8771        .unwrap();
8772        assert_eq!("https://example.test/issues/101", url);
8773    }
8774
8775    #[test]
8776    fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
8777        let error = reconcile_issue_creation(
8778            "child",
8779            Err(crate::error::SparError::new("permission denied")),
8780            Ok(None),
8781        )
8782        .unwrap_err();
8783        assert!(error.to_string().contains("permission denied"), "{error}");
8784    }
8785
8786    #[test]
8787    fn an_unverifiable_issue_creation_is_marked_uncertain() {
8788        let error = reconcile_issue_creation(
8789            "child",
8790            Err(crate::error::SparError::new("connection lost")),
8791            Err(crate::error::SparError::new("issues unavailable")),
8792        )
8793        .unwrap_err();
8794        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8795        assert!(!error.worth_retrying());
8796    }
8797
8798    #[test]
8799    fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
8800        let result = reconcile_failed_split_push(
8801            "split-34-1",
8802            crate::error::SparError::new("connection lost"),
8803            Ok("abc123\n".into()),
8804            Ok("abc123\trefs/heads/split-34-1\n".into()),
8805        );
8806        assert!(result.is_ok(), "{result:?}");
8807    }
8808
8809    #[test]
8810    fn a_split_push_collision_is_definite_and_never_overwrites() {
8811        let error = reconcile_failed_split_push(
8812            "split-34-1",
8813            crate::error::SparError::new("lease rejected"),
8814            Ok("abc123\n".into()),
8815            Ok("def456\trefs/heads/split-34-1\n".into()),
8816        )
8817        .unwrap_err();
8818        assert!(!error.retain_worktree());
8819        assert!(
8820            error.to_string().contains("Nothing was overwritten"),
8821            "{error}"
8822        );
8823    }
8824
8825    #[test]
8826    fn an_unreadable_split_push_result_keeps_the_worktree() {
8827        let error = reconcile_failed_split_push(
8828            "split-34-1",
8829            crate::error::SparError::new("connection lost"),
8830            Ok("abc123\n".into()),
8831            Err(crate::error::SparError::new("origin unavailable")),
8832        )
8833        .unwrap_err();
8834        assert!(error.retain_worktree());
8835        assert!(error.to_string().contains("could not confirm"), "{error}");
8836    }
8837
8838    /// Follow-up deduplication compares a title it computed against the title
8839    /// GitHub stored. If those two transforms can disagree, the check never
8840    /// matches and every review round files another copy of the same issue.
8841    #[test]
8842    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
8843        let repo = repo_for_titles();
8844        for raw in [
8845            "Retry loop spins \u{2014} Retry-After parses to zero",
8846            "plain title",
8847            "  spread   over\nlines  ",
8848            "\u{1F916} Generated with something",
8849            &format!("a \u{2014} {}", "very long title ".repeat(20)),
8850            &"x".repeat(300),
8851            &format!("{} \u{2014} end", "y".repeat(88)),
8852            // Exactly the budget, with two spaceless dashes. The scrub turns
8853            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
8854            // character over budget per dash and a second pass clips again,
8855            // producing a different string. Scrub-then-clip cannot.
8856            &{
8857                let tail = "a\u{2014}b c\u{2014}d";
8858                let pad = Style::default().max_title_chars - tail.chars().count();
8859                format!("{}{tail}", "w".repeat(pad))
8860            },
8861        ] {
8862            let once = repo.clean_title(raw).unwrap();
8863            let twice = repo.clean_title(&once).unwrap();
8864            assert_eq!(once, twice, "not idempotent for {raw:?}");
8865            assert!(
8866                once.chars().count() <= repo.style.max_title_chars,
8867                "over budget: {once:?}"
8868            );
8869            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
8870        }
8871    }
8872
8873    #[test]
8874    fn a_title_with_an_em_dash_survives_as_readable_text() {
8875        let repo = repo_for_titles();
8876        assert_eq!(
8877            "Retry loop spins, Retry-After parses to zero",
8878            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
8879                .unwrap()
8880        );
8881    }
8882
8883    #[test]
8884    fn sh_quote_survives_a_quote() {
8885        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
8886    }
8887
8888    #[test]
8889    fn sh_quote_wraps_a_space() {
8890        assert_eq!(
8891            "'/Applications/My App/spar'",
8892            sh_quote("/Applications/My App/spar")
8893        );
8894    }
8895
8896    #[test]
8897    fn finished_states_are_recognised_case_insensitively() {
8898        assert!(is_finished("MERGED"));
8899        assert!(is_finished("closed"));
8900        assert!(!is_finished("OPEN"));
8901        assert!(!is_finished(""));
8902    }
8903
8904    fn state() -> PersistedState {
8905        PersistedState {
8906            version: 1,
8907            checkpoint: 0,
8908            round: 4,
8909            next_actor: "codex".into(),
8910            status: Status::Pending,
8911            pr_head: "abc123".into(),
8912            ledger: Ledger::new(),
8913            filed: vec![],
8914            open_findings: vec![Finding {
8915                severity: Severity::Blocking,
8916                title: "Unchecked error".into(),
8917                detail: "the failure is discarded".into(),
8918                file: "src/a.rs:12".into(),
8919                ..Finding::default()
8920            }],
8921            disputes: vec![Dispute {
8922                title: "Retry limit".into(),
8923                file: "src/net.rs".into(),
8924                reasoning: "the caller already bounds it".into(),
8925            }],
8926            noted: vec![Finding {
8927                severity: Severity::NonBlocking,
8928                title: "Timeout is fixed".into(),
8929                file: "src/config.rs".into(),
8930                ..Finding::default()
8931            }],
8932        }
8933    }
8934
8935    #[test]
8936    fn a_state_comment_round_trips() {
8937        let body = format!(
8938            "{STATE_MARKER}\n{}\n-->",
8939            serde_json::to_string(&state()).unwrap()
8940        );
8941        let back = parse_state_comment(&body).unwrap();
8942        assert_eq!(4, back.round);
8943        assert_eq!("codex", back.next_actor);
8944        assert_eq!("abc123", back.pr_head);
8945        assert_eq!("Unchecked error", back.open_findings[0].title);
8946        assert_eq!("src/net.rs", back.disputes[0].file);
8947        assert_eq!("Timeout is fixed", back.noted[0].title);
8948    }
8949
8950    #[test]
8951    fn old_state_without_new_lists_still_parses() {
8952        let body = format!(
8953            "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
8954             \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
8955        );
8956        let back = parse_state_comment(&body).expect("old state");
8957        assert!(back.open_findings.is_empty());
8958        assert!(back.disputes.is_empty());
8959        assert!(back.noted.is_empty());
8960        assert!(back.pr_head.is_empty());
8961        assert_eq!(0, back.checkpoint);
8962    }
8963
8964    #[test]
8965    fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
8966        let mut local = state();
8967        local.pr_head = "old".into();
8968        local.round = 9;
8969        let mut remote = state();
8970        remote.pr_head = "current".into();
8971        remote.round = 4;
8972
8973        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
8974        assert_eq!("current", chosen.pr_head);
8975        assert_eq!(4, chosen.round);
8976    }
8977
8978    #[test]
8979    fn checkpoint_order_breaks_same_round_ties() {
8980        let mut local = state();
8981        local.pr_head = "current".into();
8982        local.round = 4;
8983        local.checkpoint = 8;
8984        let mut remote = local.clone();
8985        remote.checkpoint = 7;
8986        remote.open_findings.clear();
8987
8988        let chosen = choose_state_for_head(vec![local], "current").unwrap();
8989        assert_eq!(8, chosen.checkpoint);
8990
8991        let mut local = state();
8992        local.pr_head = "current".into();
8993        local.round = 4;
8994        local.checkpoint = 8;
8995        let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
8996        assert_eq!(8, chosen.checkpoint);
8997    }
8998
8999    #[test]
9000    fn legacy_same_round_tie_keeps_the_local_checkpoint() {
9001        let mut local = state();
9002        local.pr_head = "current".into();
9003        local.round = 4;
9004        local.open_findings.push(Finding {
9005            title: "local checkpoint".into(),
9006            ..Finding::default()
9007        });
9008        let mut remote = state();
9009        remote.pr_head = "current".into();
9010        remote.round = 4;
9011
9012        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
9013        assert_eq!(
9014            "local checkpoint",
9015            chosen.open_findings.last().unwrap().title
9016        );
9017    }
9018
9019    /// It must render as nothing, so PRs are not littered with machine state.
9020    #[test]
9021    fn the_state_block_is_an_html_comment() {
9022        let body = format!(
9023            "{STATE_MARKER}\n{}\n-->",
9024            serde_json::to_string(&state()).unwrap()
9025        );
9026        assert!(body.starts_with("<!--"));
9027        assert!(body.trim_end().ends_with("-->"));
9028        assert!(!body[..body.find('{').unwrap()].contains("-->"));
9029    }
9030
9031    #[test]
9032    fn an_unrelated_json_block_is_not_state() {
9033        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
9034    }
9035
9036    /// Patching and deleting a comment is not something to do on a substring.
9037    ///
9038    /// A maintainer who notices the block and asks about it writes a comment
9039    /// containing `spar:state`, and the loose filter could not tell theirs from
9040    /// spar's. The next `persist` replaced their words with JSON and
9041    /// `spar clean --pr-state` later deleted it.
9042    #[test]
9043    fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
9044        let block = format!(
9045            "{STATE_MARKER}\n{}\n-->",
9046            serde_json::to_string(&state()).unwrap()
9047        );
9048        assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
9049        assert!(
9050            is_state_comment(&block, "Spar-Bot", "spar-bot"),
9051            "a login differs only in case"
9052        );
9053
9054        assert!(
9055            !is_state_comment(
9056                "why is there a `<!-- spar:state` block on this PR?",
9057                "maintainer",
9058                "spar-bot"
9059            ),
9060            "a question about the block was treated as the block"
9061        );
9062        assert!(
9063            !is_state_comment(&block, "maintainer", "spar-bot"),
9064            "somebody else's comment is not spar's to rewrite"
9065        );
9066        assert!(
9067            !is_state_comment(
9068                &format!("here is what spar writes:\n\n{block}"),
9069                "spar-bot",
9070                "spar-bot"
9071            ),
9072            "the marker has to open the body, not appear in it"
9073        );
9074        assert!(
9075            !is_state_comment(
9076                &format!("{STATE_MARKER}\n{{not json\n-->"),
9077                "spar-bot",
9078                "spar-bot"
9079            ),
9080            "a block that does not parse is not state"
9081        );
9082        assert!(
9083            !is_state_comment(&block, "spar-bot", ""),
9084            "an unknown viewer proves nothing about who wrote it"
9085        );
9086    }
9087
9088    #[test]
9089    fn a_malformed_state_comment_is_none_not_a_panic() {
9090        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
9091    }
9092
9093    #[test]
9094    fn atomic_write_leaves_no_temp_file() {
9095        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
9096        let _ = std::fs::remove_dir_all(&dir);
9097        let path = dir.join("state").join("pr-7.json");
9098        write_json_atomic(&path, &state()).unwrap();
9099        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
9100            .unwrap()
9101            .flatten()
9102            .filter_map(|e| e.file_name().to_str().map(str::to_string))
9103            .collect();
9104        assert_eq!(vec!["pr-7.json".to_string()], files);
9105        let _ = std::fs::remove_dir_all(&dir);
9106    }
9107
9108    #[test]
9109    fn atomic_write_overwrites_rather_than_accumulating() {
9110        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
9111        let _ = std::fs::remove_dir_all(&dir);
9112        let path = dir.join("pr-7.json");
9113        for round in 1..4 {
9114            let mut s = state();
9115            s.round = round;
9116            write_json_atomic(&path, &s).unwrap();
9117        }
9118        let back: PersistedState =
9119            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
9120        assert_eq!(3, back.round);
9121        let _ = std::fs::remove_dir_all(&dir);
9122    }
9123
9124    #[test]
9125    fn style_from_env_defaults_to_enforcing() {
9126        std::env::remove_var("SPAR_BAN_EM_DASH");
9127        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
9128        let style = style_from_env();
9129        assert!(style.ban_em_dash && style.ban_ai_attribution);
9130        assert!(
9131            !style.terse,
9132            "the commit filter must not truncate a commit message"
9133        );
9134    }
9135}
9136
9137#[cfg(test)]
9138mod comment_page_tests {
9139    use super::*;
9140
9141    #[test]
9142    fn a_single_merged_array_is_read() {
9143        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
9144        assert_eq!(2, pages.len());
9145        assert_eq!(Some(2), pages[1]["id"].as_i64());
9146    }
9147
9148    #[test]
9149    fn concatenated_pages_from_an_older_gh_are_read_too() {
9150        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
9151        assert_eq!(2, pages.len());
9152    }
9153
9154    /// A comment body containing a bracket pair used to split the payload into
9155    /// two invalid halves, so no state comment was found and a resume silently
9156    /// started from round one.
9157    #[test]
9158    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
9159        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
9160        let pages = parse_comment_pages(text);
9161        assert_eq!(2, pages.len(), "{pages:?}");
9162        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
9163    }
9164
9165    #[test]
9166    fn empty_output_is_no_comments_not_a_panic() {
9167        assert!(parse_comment_pages("").is_empty());
9168        assert!(parse_comment_pages("   ").is_empty());
9169        assert!(parse_comment_pages("[]").is_empty());
9170    }
9171
9172    #[test]
9173    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
9174        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
9175    }
9176
9177    #[test]
9178    fn a_write_postcheck_rejects_truncated_comment_pages() {
9179        let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
9180        assert!(
9181            error.to_string().contains("unexpected comment pages"),
9182            "{error}"
9183        );
9184    }
9185
9186    #[test]
9187    fn a_write_postcheck_rejects_empty_or_non_array_output() {
9188        assert!(try_parse_comment_pages("").is_err());
9189        assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
9190        assert!(try_parse_comment_pages("[]").is_ok());
9191    }
9192
9193    #[test]
9194    fn state_is_found_in_the_last_matching_comment() {
9195        let payload = |round: u32| {
9196            format!(
9197                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
9198            )
9199        };
9200        let text = serde_json::to_string(&serde_json::json!([
9201            {"id": 1, "body": payload(1)},
9202            {"id": 2, "body": "looks good to me"},
9203            {"id": 3, "body": payload(5)},
9204        ]))
9205        .unwrap();
9206        let pages = parse_comment_pages(&text);
9207        let last = pages
9208            .iter()
9209            .rev()
9210            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
9211            .unwrap();
9212        assert_eq!(5, last.round);
9213    }
9214}
9215
9216#[cfg(test)]
9217mod linked_pr_tests {
9218    use super::*;
9219
9220    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
9221    /// It carries an id and a whole repository object per linked issue, and a
9222    /// parser that chokes on those reports "no pull request", which is the one
9223    /// answer that makes spar implement over the top of somebody's work.
9224    const REAL_PAYLOAD: &str = r#"[
9225      {"number":14252,"title":"fix: reject leading-dash branch names",
9226       "url":"https://github.com/cli/cli/pull/14252",
9227       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
9228         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
9229         "url":"https://github.com/cli/cli/issues/14238"}]},
9230      {"number":14217,"title":"another change",
9231       "url":"https://github.com/cli/cli/pull/14217",
9232       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
9233         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
9234         "url":"https://github.com/cli/cli/issues/9761"}]},
9235      {"number":14200,"title":"unlinked work",
9236       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
9237    ]"#;
9238
9239    #[test]
9240    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
9241        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
9242        assert_eq!(14252, pr.number);
9243        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
9244    }
9245
9246    #[test]
9247    fn the_right_pr_is_picked_out_of_several() {
9248        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
9249    }
9250
9251    #[test]
9252    fn an_issue_nobody_is_working_on_finds_nothing() {
9253        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
9254    }
9255
9256    #[test]
9257    fn an_unlinked_pr_is_never_matched() {
9258        // 14200 closes nothing, so no issue number should ever return it.
9259        for issue in [14200, 0, 1] {
9260            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
9261                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
9262            }
9263        }
9264    }
9265
9266    #[test]
9267    fn empty_or_broken_output_is_none_rather_than_a_panic() {
9268        assert!(find_linked_pr("", 1).is_none());
9269        assert!(find_linked_pr("[]", 1).is_none());
9270        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
9271        assert!(find_linked_pr("[{\"number\":", 1).is_none());
9272    }
9273
9274    /// "gh could not answer" and "nobody is working on it" are different
9275    /// answers, and only one of them means implement from scratch.
9276    ///
9277    /// The parse used to fold both into `None`, so an authentication failure or
9278    /// a rate limit read as an empty list and spar opened a second pull request
9279    /// for an issue somebody already had work open on.
9280    #[test]
9281    fn an_unreadable_list_is_not_an_empty_list() {
9282        assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
9283        assert!(linked_pr_rows("[{\"number\":").is_none());
9284        assert!(linked_pr_rows("").is_none());
9285
9286        let empty = linked_pr_rows("[]").expect("an empty list is an answer");
9287        assert!(empty.is_empty());
9288        assert!(pick_linked_pr(&empty, 14238).is_none());
9289
9290        let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
9291        assert_eq!(3, listed.len());
9292        assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
9293    }
9294
9295    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
9296    #[test]
9297    fn pr_view_reads_the_cross_repository_flag() {
9298        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
9299                       "baseRefName":"main","state":"OPEN",
9300                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
9301        let pr: PrView = serde_json::from_str(json).unwrap();
9302        assert!(pr.is_cross_repository);
9303        assert!(pr.is_open());
9304
9305        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
9306        assert!(
9307            !serde_json::from_str::<PrView>(&same_repo)
9308                .unwrap()
9309                .is_cross_repository
9310        );
9311    }
9312}
9313
9314#[cfg(test)]
9315mod min_number_tests {
9316    /// The floor is applied before the cap, which is the order that matters.
9317    /// spar takes the *lowest* numbered open items, so a repository with a tail
9318    /// of old issues would otherwise spend its whole run in the tail: the cap
9319    /// would be filled by the oldest items and the floor would never be
9320    /// reached. Filtering first is what makes the setting do anything.
9321    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
9322        let mut numbers: Vec<i64> = open.to_vec();
9323        numbers.sort_unstable();
9324        if min_number > 0 {
9325            numbers.retain(|n| *n >= min_number);
9326        }
9327        numbers.truncate(limit);
9328        numbers
9329    }
9330
9331    #[test]
9332    fn the_floor_is_applied_before_the_cap_not_after() {
9333        let open = [12, 13, 14, 480, 481, 482];
9334        assert_eq!(vec![480, 481], pick(&open, 2, 480));
9335        // Capping first would have returned the two oldest and then filtered
9336        // them all away, leaving nothing.
9337        assert!(!pick(&open, 2, 480).is_empty());
9338    }
9339
9340    #[test]
9341    fn no_floor_keeps_the_old_behaviour() {
9342        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
9343    }
9344
9345    #[test]
9346    fn the_floor_is_inclusive() {
9347        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
9348    }
9349
9350    #[test]
9351    fn a_floor_above_everything_open_yields_nothing() {
9352        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
9353    }
9354}