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    /// The generated paths the check let through, for one report per attempt
2572    /// rather than one per check.
2573    fn allow_generated_ignored_files(
2574        &self,
2575        cwd: &Path,
2576        baseline: &WorktreeBaseline,
2577    ) -> Result<AllowedArtifacts> {
2578        self.check_new_ignored_files(cwd, baseline)
2579    }
2580
2581    fn check_new_ignored_files(
2582        &self,
2583        cwd: &Path,
2584        baseline: &WorktreeBaseline,
2585    ) -> Result<AllowedArtifacts> {
2586        self.refuse_changed_attributes(cwd, baseline)?;
2587        let after = ignored_untracked_state(cwd).map_err(|e| {
2588            uncertain_worktree_change(
2589                cwd,
2590                format!(
2591                    "could not verify untracked files in {} after editing: {}. The worktree was \
2592                     kept for recovery.",
2593                    cwd.display(),
2594                    e.last_line()
2595                ),
2596            )
2597        })?;
2598        let changed = baseline.ignored_untracked.changed_paths(&after);
2599        if changed.is_empty() {
2600            return Ok(AllowedArtifacts::default());
2601        }
2602        let (generated, changed): (Vec<_>, Vec<_>) = changed
2603            .into_iter()
2604            .partition(|path| baseline.ignored_untracked.generated_move(&after, path));
2605        if changed.is_empty() {
2606            return Ok(AllowedArtifacts::split(generated, &after));
2607        }
2608        let listed = list_paths(&changed);
2609        Err(uncertain_worktree_change(
2610            cwd,
2611            format!(
2612                "the editing call created or changed untracked or ignored file(s) in {} that \
2613                 cannot be represented by a managed commit: {listed}. The worktree was kept for \
2614                 recovery.",
2615                cwd.display()
2616            ),
2617        ))
2618    }
2619
2620    /// Existing untracked files belong to the checkout owner, even when a call
2621    /// also produces a valid tracked change. Refuse their modification or
2622    /// deletion before accepting the tracked result. Rebuilt output is not that:
2623    /// it is ignored on both sides and under a known build or cache directory,
2624    /// which is where the command the call was asked to run puts it.
2625    pub(crate) fn refuse_changed_existing_untracked(
2626        &self,
2627        cwd: &Path,
2628        baseline: &WorktreeBaseline,
2629    ) -> Result<()> {
2630        self.check_changed_existing_untracked(cwd, baseline)
2631            .map(drop)
2632    }
2633
2634    fn allow_changed_generated_artifacts(
2635        &self,
2636        cwd: &Path,
2637        baseline: &WorktreeBaseline,
2638    ) -> Result<AllowedArtifacts> {
2639        self.check_changed_existing_untracked(cwd, baseline)
2640    }
2641
2642    fn check_changed_existing_untracked(
2643        &self,
2644        cwd: &Path,
2645        baseline: &WorktreeBaseline,
2646    ) -> Result<AllowedArtifacts> {
2647        self.refuse_changed_attributes(cwd, baseline)?;
2648        let after = ignored_untracked_state(cwd).map_err(|e| {
2649            uncertain_worktree_change(
2650                cwd,
2651                format!(
2652                    "could not verify existing untracked files in {} after editing: {}. The \
2653                     worktree was kept for recovery.",
2654                    cwd.display(),
2655                    e.last_line()
2656                ),
2657            )
2658        })?;
2659        let changed = baseline.ignored_untracked.changed_existing_paths(&after);
2660        if changed.is_empty() {
2661            return Ok(AllowedArtifacts::default());
2662        }
2663        let (generated, changed): (Vec<_>, Vec<_>) = changed
2664            .into_iter()
2665            .partition(|path| baseline.ignored_untracked.generated_move(&after, path));
2666        if changed.is_empty() {
2667            return Ok(AllowedArtifacts::split(generated, &after));
2668        }
2669        let listed = list_paths(&changed);
2670        Err(uncertain_worktree_change(
2671            cwd,
2672            format!(
2673                "the editing call changed or deleted existing untracked file(s) in {}: \
2674                 {listed}. The worktree was kept for recovery.",
2675                cwd.display()
2676            ),
2677        ))
2678    }
2679
2680    /// Refuse a byte or mode change that the index did not represent.
2681    ///
2682    /// Clean filters can normalize a working file back to its existing blob,
2683    /// and index flags can hide a change from porcelain status. Comparing the
2684    /// actual tracked files on both sides keeps those bytes from being treated
2685    /// as disposable just because Git has no diff for them.
2686    pub(crate) fn refuse_unrepresented_tracked_changes(
2687        &self,
2688        cwd: &Path,
2689        baseline: &WorktreeBaseline,
2690    ) -> Result<()> {
2691        self.refuse_changed_attributes(cwd, baseline)?;
2692        let after = safe_git_state(cwd).map_err(|e| {
2693            uncertain_worktree_change(
2694                cwd,
2695                format!(
2696                    "could not verify tracked files in {} after editing: {}. The worktree was \
2697                     kept for recovery.",
2698                    cwd.display(),
2699                    e.last_line()
2700                ),
2701            )
2702        })?;
2703        let mut changed = Vec::new();
2704        let before_filter_untracked = ignored_untracked_state(cwd).map_err(|e| {
2705            uncertain_worktree_change(
2706                cwd,
2707                format!(
2708                    "could not record untracked files before verifying transformed content in {}: \
2709                     {}. The worktree was kept for recovery.",
2710                    cwd.display(),
2711                    e.last_line()
2712                ),
2713            )
2714        })?;
2715        let mut filter_was_run = false;
2716        let mut filter_problem = None;
2717        let mut repositories: BTreeSet<PathBuf> =
2718            baseline.git_state.repositories.keys().cloned().collect();
2719        repositories.extend(after.repositories.keys().cloned());
2720        'repositories: for repository_path in repositories {
2721            let before_repository = baseline.git_state.repositories.get(&repository_path);
2722            let after_repository = after.repositories.get(&repository_path);
2723            if before_repository.is_none() || after_repository.is_none() {
2724                changed.push(repository_path.clone());
2725                continue;
2726            }
2727            if before_repository.map(|repository| &repository.gitlinks)
2728                != after_repository.map(|repository| &repository.gitlinks)
2729            {
2730                changed.push(repository_path.join("<gitlinks>"));
2731            }
2732            let mut paths = BTreeSet::new();
2733            if let Some(repository) = before_repository {
2734                paths.extend(repository.tracked.keys().cloned());
2735            }
2736            if let Some(repository) = after_repository {
2737                paths.extend(repository.tracked.keys().cloned());
2738            }
2739            for path in paths {
2740                let before = before_repository.and_then(|repository| repository.tracked.get(&path));
2741                let current = after_repository.and_then(|repository| repository.tracked.get(&path));
2742                let worktree_changed =
2743                    before.map(|entry| &entry.worktree) != current.map(|entry| &entry.worktree);
2744                let index_changed = before.map(|entry| (&entry.index_mode, &entry.index_oid))
2745                    != current.map(|entry| (&entry.index_mode, &entry.index_oid));
2746                if !worktree_changed {
2747                    continue;
2748                }
2749                if !index_changed {
2750                    changed.push(repository_path.join(&path));
2751                    continue;
2752                }
2753                let before_worktree = before.and_then(|entry| entry.worktree.as_ref());
2754                let current_worktree = current.and_then(|entry| entry.worktree.as_ref());
2755                let Some(current_entry) = current else {
2756                    continue;
2757                };
2758                let Some(current_worktree) = current_worktree else {
2759                    continue;
2760                };
2761                let content_changed =
2762                    before_worktree.map(|file| file.content) != Some(current_worktree.content);
2763                let mode_changed = before_worktree.map(|file| file.mode.as_str())
2764                    != Some(current_worktree.mode.as_str());
2765                let repository = cwd.join(&repository_path);
2766                let represented_content = if content_changed {
2767                    filter_was_run = true;
2768                    let result =
2769                        filtered_index_content(&repository, &path, &current_entry.index_oid);
2770                    self.refuse_changed_attributes(cwd, baseline)?;
2771                    match result {
2772                        Ok(expected) => expected == current_worktree.content,
2773                        Err(error) => {
2774                            filter_problem = Some(format!(
2775                                "could not verify transformed content for {:?}: {}",
2776                                repository_path.join(&path),
2777                                error.last_line()
2778                            ));
2779                            false
2780                        }
2781                    }
2782                } else {
2783                    true
2784                };
2785                let represented_mode =
2786                    !mode_changed || current_worktree.mode == current_entry.index_mode;
2787                if !represented_content || !represented_mode {
2788                    changed.push(repository_path.join(&path));
2789                }
2790                if filter_problem.is_some() {
2791                    break 'repositories;
2792                }
2793            }
2794        }
2795        if filter_was_run {
2796            self.refuse_changed_attributes(cwd, baseline)?;
2797            let verified = safe_git_state(cwd).map_err(|e| {
2798                uncertain_worktree_change(
2799                    cwd,
2800                    format!(
2801                        "could not recheck tracked files after verifying transformed content in \
2802                         {}: {}. The worktree was kept for recovery.",
2803                        cwd.display(),
2804                        e.last_line()
2805                    ),
2806                )
2807            })?;
2808            let verified_untracked = ignored_untracked_state(cwd).map_err(|e| {
2809                uncertain_worktree_change(
2810                    cwd,
2811                    format!(
2812                        "could not recheck untracked files after verifying transformed content \
2813                         in {}: {}. The worktree was kept for recovery.",
2814                        cwd.display(),
2815                        e.last_line()
2816                    ),
2817                )
2818            })?;
2819            if verified != after
2820                || before_filter_untracked.changed_beyond_generated(&verified_untracked)
2821            {
2822                return Err(uncertain_worktree_change(
2823                    cwd,
2824                    "a content filter changed the worktree while SPAR verified the managed \
2825                     commit. The worktree was kept for recovery.",
2826                ));
2827            }
2828            self.refuse_changed_existing_untracked(cwd, baseline)?;
2829        }
2830        if let Some(problem) = filter_problem {
2831            return Err(uncertain_worktree_change(
2832                cwd,
2833                format!("{problem}. The worktree was kept for recovery."),
2834            ));
2835        }
2836        if changed.is_empty() {
2837            return Ok(());
2838        }
2839        let listed = list_paths(&changed);
2840        Err(uncertain_worktree_change(
2841            cwd,
2842            format!(
2843                "the editing call changed tracked working-file bytes, modes, repositories, or \
2844                 gitlinks outside an accepted commit: {listed}. The worktree was kept for \
2845                 recovery."
2846            ),
2847        ))
2848    }
2849
2850    pub(crate) fn refuse_changed_attributes(
2851        &self,
2852        cwd: &Path,
2853        baseline: &WorktreeBaseline,
2854    ) -> Result<()> {
2855        let after = attribute_state(cwd).map_err(|e| {
2856            uncertain_worktree_change(
2857                cwd,
2858                format!(
2859                    "could not verify attribute files in {} after editing: {}. The worktree was \
2860                     kept for recovery.",
2861                    cwd.display(),
2862                    e.last_line()
2863                ),
2864            )
2865        })?;
2866        if after == baseline.attributes {
2867            return Ok(());
2868        }
2869        Err(uncertain_worktree_change(
2870            cwd,
2871            format!(
2872                "the editing call changed a .gitattributes file in {}. It was kept, but SPAR \
2873                 refused to run a Git operation that could select a new external filter.",
2874                cwd.display()
2875            ),
2876        ))
2877    }
2878
2879    /// Commit a successful editing call from the trusted harness process.
2880    ///
2881    /// Editing sandboxes only need the working tree. They never need writable
2882    /// access to the repository's object database, refs, config, or hooks.
2883    pub(crate) fn commit_pending_changes(
2884        &self,
2885        cwd: &Path,
2886        baseline: &WorktreeBaseline,
2887        preferred_subject: &str,
2888        fallback_subject: &str,
2889    ) -> Result<bool> {
2890        let mut artifacts = GeneratedArtifacts::default();
2891        self.refuse_changed_attributes(cwd, baseline)?;
2892        artifacts.changed(self.allow_changed_generated_artifacts(cwd, baseline)?);
2893        refuse_unsafe_index_flags(cwd)?;
2894        if !self.has_uncommitted_changes(cwd)? {
2895            artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2896            artifacts.report(cwd);
2897            return Ok(false);
2898        }
2899        self.stage_managed_changes(cwd, baseline).map_err(|e| {
2900            e.with_message(format!(
2901                "could not stage changes in {}: {}",
2902                cwd.display(),
2903                e.last_line()
2904            ))
2905        })?;
2906        // Ignored paths remain untracked after staging. Only known generated
2907        // output may remain beside an otherwise complete managed commit.
2908        artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2909        let changed_gitlinks = changed_staged_gitlinks(cwd)?;
2910        if !changed_gitlinks.is_empty() {
2911            let listed = list_paths(&changed_gitlinks);
2912            bail!(
2913                "the editing call added or changed a gitlink at {listed}. It was staged but not \
2914                 committed because the referenced repository objects might exist only inside \
2915                 this worktree. The worktree was kept for recovery."
2916            );
2917        }
2918        let mut subject = self.clean_title(preferred_subject)?;
2919        if subject.trim().is_empty() {
2920            subject = self.clean_title(fallback_subject)?;
2921        }
2922        self.commit_staged_changes(cwd, &subject).map_err(|e| {
2923            e.with_message(format!(
2924                "could not commit changes in {}: {}. The staged files were kept.",
2925                cwd.display(),
2926                e.last_line()
2927            ))
2928        })?;
2929        if has_tracked_or_staged_work(cwd)? {
2930            bail!(
2931                "the commit in {} left additional uncommitted files. They were kept for \
2932                 recovery.",
2933                cwd.display()
2934            );
2935        }
2936        artifacts.changed(self.allow_changed_generated_artifacts(cwd, baseline)?);
2937        artifacts.left(self.allow_generated_ignored_files(cwd, baseline)?);
2938        artifacts.report(cwd);
2939        Ok(true)
2940    }
2941
2942    fn stage_managed_changes(&self, cwd: &Path, baseline: &WorktreeBaseline) -> Result<()> {
2943        let after = ignored_untracked_state(cwd)?;
2944        self.git_at_without_automation(cwd, &["add", "-u"])?;
2945        let paths = baseline.ignored_untracked.new_ordinary_paths(&after);
2946        if paths.is_empty() {
2947            return Ok(());
2948        }
2949        let mut input = Vec::new();
2950        for path in paths {
2951            input.extend(os_str_bytes(path.as_os_str())?);
2952            input.push(0);
2953        }
2954        let argv = git_without_automation_argv(&[
2955            "--literal-pathspecs",
2956            "add",
2957            "--pathspec-from-file=-",
2958            "--pathspec-file-nul",
2959        ]);
2960        proc::run_with_input_bytes(
2961            &argv,
2962            &self.git_opts(Some(cwd), true).stop_descendants(true),
2963            &input,
2964        )?;
2965        Ok(())
2966    }
2967
2968    /// Commit an index prepared by the parent without signing, hooks, or
2969    /// inherited repository automation.
2970    pub(crate) fn commit_staged_changes(&self, cwd: &Path, subject: &str) -> Result<()> {
2971        self.git_at_without_automation(cwd, &["commit", "--no-verify", "-m", subject])
2972            .map(|_| ())
2973    }
2974
2975    /// How many commits `refname` carries that the base does not.
2976    ///
2977    /// Counted from the commits themselves rather than from `commit_subjects`,
2978    /// which drops a commit whose message is empty. The guards in
2979    /// `worktree_add` decide whether to delete a branch on this number, and an
2980    /// empty message must not read as an empty branch.
2981    pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
2982        let range = format!("{}..{refname}", self.base_ref(cwd, base));
2983        self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
2984            .trim()
2985            .parse()
2986            .unwrap_or(0)
2987    }
2988
2989    /// One `hash subject` line per commit `refname` carries that the base does
2990    /// not, oldest first. For showing a person what is on a branch, so the
2991    /// hash keeps a commit with no message from listing as nothing.
2992    pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
2993        let range = format!("{}..{refname}", self.base_ref(cwd, base));
2994        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
2995            .lines()
2996            .map(str::to_string)
2997            .collect()
2998    }
2999
3000    /// The commits `later` carries that `earlier` does not, oldest first, when
3001    /// `earlier` is genuinely behind it.
3002    ///
3003    /// `None` when it is not an ancestor, which is not the same as nothing
3004    /// having landed. `rewrite_commits_if_needed` rewrites hashes from the first
3005    /// offending commit spar made onward, so a head recorded part way through a
3006    /// round can still be a readable object and no longer be on the branch. `git log` answers that
3007    /// with every commit on the branch, so without the check the one caller
3008    /// would report the whole branch as unread, which is the widest possible
3009    /// wrong answer.
3010    ///
3011    /// No `base_ref` resolution, unlike its neighbours: these are commits rather
3012    /// than branch names, and putting a sha through it logs a fallback line
3013    /// every time.
3014    pub fn commits_since(&self, cwd: &Path, earlier: &str, later: &str) -> Option<Vec<String>> {
3015        let ancestor = self
3016            .git_at(Some(cwd), &["merge-base", "--is-ancestor", earlier, later])
3017            .is_ok();
3018        if !ancestor {
3019            return None;
3020        }
3021        let range = format!("{earlier}..{later}");
3022        Some(
3023            self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
3024                .lines()
3025                .map(str::to_string)
3026                .collect(),
3027        )
3028    }
3029
3030    /// The subjects of the commits `refname` carries that the base does not,
3031    /// oldest first.
3032    pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
3033        let range = format!("{}..{refname}", self.base_ref(cwd, base));
3034        self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
3035            .lines()
3036            .map(str::trim)
3037            .filter(|line| !line.is_empty())
3038            .map(str::to_string)
3039            .collect()
3040    }
3041
3042    /// The paths this checkout changes relative to the base, sorted.
3043    ///
3044    /// A three dot range, matching `diff_stat`: what the branch did, not what
3045    /// the base has done since.
3046    ///
3047    /// `--no-renames` because a rename reported as its destination alone leaves
3048    /// the source out of the list, and a part carrying only the destination
3049    /// would be a copy. As a deletion and an addition it is two paths, which a
3050    /// part can carry together or leave to the leftover report.
3051    ///
3052    /// `-z` because without it git writes paths for display: anything
3053    /// non-ASCII comes back escaped and wrapped in quotes, and that string is
3054    /// not a path. A part built from one carries a pathspec matching no file,
3055    /// so the file never reaches the slice while every list still says the part
3056    /// took it. It also keeps a path with a space at either end intact.
3057    pub fn changed_files(&self, cwd: &Path, base: &str) -> Vec<String> {
3058        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3059        self.git_try_at(
3060            Some(cwd),
3061            &["diff", "--name-only", "--no-renames", "-z", &range],
3062        )
3063        .split('\0')
3064        .filter(|path| !path.is_empty())
3065        .map(str::to_string)
3066        .collect()
3067    }
3068
3069    /// The files one commit range touched, for checking a claim against the
3070    /// diff rather than against the report that made it.
3071    pub fn files_changed_between(&self, cwd: &Path, from: &str, to: &str) -> Vec<String> {
3072        self.git_try_at(
3073            Some(cwd),
3074            &[
3075                "diff",
3076                "--name-only",
3077                "--no-renames",
3078                "-z",
3079                &format!("{from}..{to}"),
3080            ],
3081        )
3082        .split('\0')
3083        .filter(|path| !path.is_empty())
3084        .map(str::to_string)
3085        .collect()
3086    }
3087
3088    /// Where `refname` left the base: the commit its own change is measured
3089    /// from, and the one a slice of that change has to be taken against.
3090    pub fn merge_base(&self, cwd: &Path, base: &str, refname: &str) -> Result<String> {
3091        let base_ref = self.base_ref(cwd, base);
3092        let out = self
3093            .git_at(Some(cwd), &["merge-base", &base_ref, refname])
3094            .map_err(|e| {
3095                spar_err!(
3096                    "could not find where {refname} and {base_ref} diverged. {}",
3097                    e.last_line()
3098                )
3099            })?;
3100        let sha = out.trim().to_string();
3101        if sha.is_empty() {
3102            bail!("{refname} and {base_ref} share no history");
3103        }
3104        Ok(sha)
3105    }
3106
3107    pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
3108        let range = format!("{}...HEAD", self.base_ref(cwd, base));
3109        let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
3110        full.trim().to_string()
3111    }
3112
3113    /// Scrub commit messages that slipped past the prompt.
3114    ///
3115    /// `git filter-branch` calls back into this same binary, so there is no
3116    /// interpreter to find and no second copy of the rules to drift.
3117    ///
3118    /// `floor` is the head spar found when it started, and nothing at or below
3119    /// it is rewritten. The style gate is for what spar writes, and a person's
3120    /// commit message is not that: rewriting one changes a published SHA, which
3121    /// diverges the branch under somebody's own checkout and is exactly the
3122    /// history rewrite this design promises never to do. A `None` floor means
3123    /// every commit above the base is spar's own, which is true of a branch
3124    /// spar created in this invocation and of nothing else.
3125    pub fn rewrite_commits_if_needed(
3126        &self,
3127        cwd: &Path,
3128        base: &str,
3129        floor: Option<&str>,
3130    ) -> Result<()> {
3131        let base_ref = self.base_ref(cwd, base);
3132        let floor = floor.filter(|f| !f.trim().is_empty()).unwrap_or(&base_ref);
3133        self.report_commits_left_alone(cwd, &base_ref, floor);
3134
3135        let range = format!("{floor}..HEAD");
3136        let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
3137
3138        let offenders = raw
3139            .split('\x1e')
3140            .filter_map(|entry| entry.split_once('\0'))
3141            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3142            .count();
3143        if offenders == 0 {
3144            return Ok(());
3145        }
3146        logdim!("{offenders} commit message(s) violated style rules, rewriting");
3147
3148        let exe = self_binary()?;
3149        let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
3150
3151        let argv: Vec<String> = [
3152            "git",
3153            "filter-branch",
3154            "-f",
3155            "--msg-filter",
3156            &filter,
3157            &range,
3158        ]
3159        .iter()
3160        .map(|s| s.to_string())
3161        .collect();
3162        let opts = ExecOpts::new()
3163            .cwd(cwd)
3164            .check(false)
3165            .timeout_secs(600)
3166            .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
3167            .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
3168            .env(
3169                "SPAR_BAN_AI_ATTRIBUTION",
3170                bool_env(self.style.ban_ai_attribution),
3171            );
3172        let _ = proc::run(&argv, &opts);
3173
3174        let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
3175        if !style::violations(&after, &self.style).is_empty() {
3176            bail!(
3177                "commit messages still violate style rules after a rewrite in {}.",
3178                cwd.display()
3179            );
3180        }
3181        Ok(())
3182    }
3183
3184    /// Say out loud when a message spar will not touch breaks the style rules.
3185    ///
3186    /// Silence here would read as "there was nothing to scrub", and the point
3187    /// of the floor is that there was something and spar chose to leave it.
3188    fn report_commits_left_alone(&self, cwd: &Path, base_ref: &str, floor: &str) {
3189        if floor == base_ref {
3190            return;
3191        }
3192        let raw = self.git_try_at(
3193            Some(cwd),
3194            &[
3195                "log",
3196                &format!("{base_ref}..{floor}"),
3197                "--format=%H%x00%B%x1e",
3198            ],
3199        );
3200        let left: Vec<&str> = raw
3201            .split('\x1e')
3202            .filter_map(|entry| entry.split_once('\0'))
3203            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
3204            .map(|(hash, _)| &hash.trim()[..hash.trim().len().min(8)])
3205            .collect();
3206        if left.is_empty() {
3207            return;
3208        }
3209        logdim!(
3210            "{} commit message(s) already on the branch break the style rules and were left \
3211             alone: {}. spar does not rewrite commits it did not write.",
3212            left.len(),
3213            left.join(", ")
3214        );
3215    }
3216
3217    /// Push by explicit refspec from HEAD, leasing the head spar reviewed.
3218    ///
3219    /// A resumed PR is checked out under a local name (`pr-N`) that does not
3220    /// match its remote branch, so pushing by branch name would resolve the
3221    /// wrong local ref or fail outright.
3222    ///
3223    /// `expected` is the remote head spar last read. A bare `--force-with-lease`
3224    /// leases the implicit tracking ref instead, which is shared across every
3225    /// worktree and is updated by any `git fetch`. The editing agent is a real
3226    /// CLI with shell access, and fetching is a normal thing for it to do: one
3227    /// `git fetch` mid round moves the tracking ref to whatever the remote holds
3228    /// and the lease then passes against a head spar never reviewed, which
3229    /// silently drops somebody's commit. Naming the head makes the push refuse
3230    /// exactly that case.
3231    pub fn push(&self, cwd: &Path, branch: &str, expected: Option<&str>) -> Result<()> {
3232        let expected = expected.map(str::trim).filter(|head| !head.is_empty());
3233        if let Some(head) = expected {
3234            self.refuse_push_that_drops(cwd, head)?;
3235        }
3236        let refspec = format!("HEAD:{branch}");
3237        let lease = match expected {
3238            Some(head) => format!("--force-with-lease=refs/heads/{branch}:{head}"),
3239            None => "--force-with-lease".to_string(),
3240        };
3241        let pushed = self
3242            .git_at(Some(cwd), &["push", &lease, "origin", &refspec])
3243            .map(|_| ())
3244            .map_err(|e| {
3245                spar_err!(
3246                    "could not push to origin/{branch}. {}\nCheck push access and whether the \
3247                     branch moved under you.",
3248                    e.last_line()
3249                )
3250            });
3251        self.record_write(pushed)
3252    }
3253
3254    /// Refuse a push whose HEAD does not contain the head spar reviewed.
3255    ///
3256    /// The lease answers the remote moving. This answers the local side moving:
3257    /// an agent that reset or rebased the worktree leaves a HEAD that would
3258    /// publish over the reviewed head rather than on top of it, and the lease
3259    /// cannot tell the difference because the remote is exactly where spar left
3260    /// it.
3261    fn refuse_push_that_drops(&self, cwd: &Path, expected: &str) -> Result<()> {
3262        let head = self.head_oid_checked(cwd)?;
3263        if head == expected || self.is_ancestor_checked(cwd, expected, &head)? {
3264            return Ok(());
3265        }
3266        bail!(
3267            "refusing to push {head} because it does not contain the reviewed head {expected}. \
3268             The worktree at {} was kept and nothing was published. Restore {expected} or reapply \
3269             the intended commits on top of it before resuming.",
3270            cwd.display()
3271        )
3272    }
3273
3274    /// Create one remote branch for a split without ever moving an existing ref.
3275    ///
3276    /// `worktree_for_split` chooses a name that is free locally and on origin,
3277    /// but another writer can still take it before the push. An empty expected
3278    /// value in the lease makes this an atomic create: it creates an absent ref,
3279    /// accepts an identical ref as a no-op, and never moves an existing ref.
3280    /// The shared `push` method cannot be used because its lease permits
3281    /// updating a ref fetched earlier.
3282    pub fn push_split_branch(
3283        &self,
3284        cwd: &Path,
3285        branch: &str,
3286    ) -> std::result::Result<(), SplitPushError> {
3287        let remote_ref = format!("refs/heads/{branch}");
3288        let lease = format!("--force-with-lease={remote_ref}:");
3289        let refspec = format!("HEAD:{remote_ref}");
3290        let result = match self.git_at(Some(cwd), &["push", &lease, "origin", &refspec]) {
3291            Ok(_) => Ok(()),
3292            Err(push_error) => {
3293                let local = self.git_at(Some(cwd), &["rev-parse", "HEAD"]);
3294                let remote = self.git(&["ls-remote", "--heads", "origin", &remote_ref]);
3295                reconcile_failed_split_push(branch, push_error, local, remote)
3296            }
3297        };
3298        self.record_write(result)
3299    }
3300
3301    // -- gh ---------------------------------------------------------------
3302
3303    pub fn gh(&self, args: &[&str]) -> Result<String> {
3304        self.gh_at(None, args)
3305    }
3306
3307    pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
3308        let mut argv = vec!["gh".to_string()];
3309        argv.extend(args.iter().map(|s| s.to_string()));
3310        proc::run(
3311            &argv,
3312            &ExecOpts::new()
3313                .cwd(cwd.unwrap_or(&self.root))
3314                .timeout_secs(300),
3315        )
3316    }
3317
3318    /// Run gh with something on its stdin.
3319    ///
3320    /// A tracker body is far too long to pass on argv, and `--body-file -` is
3321    /// how gh takes one. `proc::exec` already wires the pipe, so this is a
3322    /// sibling of `gh_at` rather than anything new.
3323    pub fn gh_stdin(&self, args: &[&str], stdin: &str) -> Result<String> {
3324        let mut argv = vec!["gh".to_string()];
3325        argv.extend(args.iter().map(|s| s.to_string()));
3326        proc::run(
3327            &argv,
3328            &ExecOpts::new()
3329                .cwd(&self.root)
3330                .timeout_secs(300)
3331                .stdin(stdin),
3332        )
3333    }
3334
3335    pub fn gh_try(&self, args: &[&str]) -> String {
3336        let mut argv = vec!["gh".to_string()];
3337        argv.extend(args.iter().map(|s| s.to_string()));
3338        proc::run(
3339            &argv,
3340            &ExecOpts::new()
3341                .cwd(&self.root)
3342                .check(false)
3343                .timeout_secs(300),
3344        )
3345        .unwrap_or_default()
3346    }
3347
3348    /// The login `gh` is authenticated as.
3349    ///
3350    /// A hard error, never a degradation. Everything spar wrote has to be
3351    /// excluded from what it answers, and custody cannot be read from git
3352    /// authorship, so this is the only thing that tells spar's own comments
3353    /// from somebody else's. Without it the failure is not "answers a bit too
3354    /// much", it is a thread where spar answers itself until somebody notices.
3355    ///
3356    /// Not cached on disk: `gh auth switch` between runs would make a stored
3357    /// answer wrong in exactly the way that produces that thread.
3358    pub fn viewer_login(&self) -> Result<&str> {
3359        if let Some(login) = self.viewer.get() {
3360            return Ok(login);
3361        }
3362        let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
3363        let login = if !rest.trim().is_empty() {
3364            rest.trim().to_string()
3365        } else {
3366            // A token that cannot read /user can still answer for itself in
3367            // GraphQL, which is the case on some Enterprise installs.
3368            self.gh(&[
3369                "api",
3370                "graphql",
3371                "-f",
3372                "query={ viewer { login } }",
3373                "--jq",
3374                ".data.viewer.login",
3375            ])
3376            .map_err(|e| {
3377                spar_err!(
3378                    "could not find out who `gh` is authenticated as, so spar cannot tell its \
3379                     own comments from anybody else's. {}\nRun `gh auth status`.",
3380                    e.last_line()
3381                )
3382            })?
3383            .trim()
3384            .to_string()
3385        };
3386        if login.is_empty() {
3387            bail!("`gh` reported an empty login. Run `gh auth status`.");
3388        }
3389        Ok(self.viewer.get_or_init(|| login))
3390    }
3391
3392    /// One issue as it stands, open or closed.
3393    ///
3394    /// `fetch_issues` reads a queue to work: it drops a closed issue and fails
3395    /// when nothing survives. Both are wrong for reading one issue back, where
3396    /// closed is an answer and the empty case cannot arise.
3397    pub fn read_issue(&self, number: i64) -> Result<Issue> {
3398        let text = self
3399            .gh(&[
3400                "issue",
3401                "view",
3402                &number.to_string(),
3403                "--json",
3404                "number,title,body,labels,state,stateReason,url",
3405            ])
3406            .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3407        serde_json::from_str(&text)
3408            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))
3409    }
3410
3411    pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
3412        let mut issues = Vec::new();
3413        for number in numbers {
3414            let text = self
3415                .gh(&[
3416                    "issue",
3417                    "view",
3418                    &number.to_string(),
3419                    "--json",
3420                    "number,title,body,labels,state,stateReason,url",
3421                ])
3422                .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
3423            let issue: Issue = serde_json::from_str(&text)
3424                .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3425            if issue.is_closed() {
3426                crate::log!("issue #{number} is closed, skipping");
3427                continue;
3428            }
3429            issues.push(issue);
3430        }
3431        if issues.is_empty() {
3432            bail!("no open issues to work on");
3433        }
3434        Ok(issues)
3435    }
3436
3437    /// Open items, lowest numbered first, from `min_number` upward.
3438    ///
3439    /// The floor exists because a long lived repository accumulates a tail of
3440    /// old issues nobody is going to get to, and taking the lowest numbered
3441    /// open items means walking straight into them.
3442    fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3443        #[derive(Deserialize)]
3444        struct Row {
3445            number: i64,
3446        }
3447        let text = self.gh(&[
3448            kind,
3449            "list",
3450            "--state",
3451            "open",
3452            "--limit",
3453            &FETCH_CEILING.to_string(),
3454            "--json",
3455            "number",
3456        ])?;
3457        let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
3458        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
3459        numbers.sort_unstable();
3460
3461        let noun = if kind == "issue" { "issues" } else { "PRs" };
3462        let found = numbers.len();
3463        if min_number > 0 {
3464            numbers.retain(|n| *n >= min_number);
3465            let skipped = found - numbers.len();
3466            if skipped > 0 {
3467                crate::log!("{skipped} open {noun} below #{min_number} skipped");
3468            }
3469        }
3470        if found >= FETCH_CEILING {
3471            crate::log!(
3472                "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
3473                 considered."
3474            );
3475        }
3476        if numbers.len() > limit {
3477            crate::log!(
3478                "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
3479                 explicitly.",
3480                numbers.len()
3481            );
3482            numbers.truncate(limit);
3483        }
3484        Ok(numbers)
3485    }
3486
3487    /// Open issues, lowest numbered first. `gh issue list` excludes PRs.
3488    pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3489        self.open_numbers("issue", limit, min_number)
3490    }
3491
3492    pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
3493        self.open_numbers("pr", limit, min_number)
3494    }
3495
3496    pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
3497        self.branch_prs(branch, "open").into_iter().next()
3498    }
3499
3500    /// The open pull request for a branch, preserving a failed lookup as an
3501    /// error when the caller is deciding whether a write already landed.
3502    pub fn try_pr_for_branch(&self, branch: &str, base: &str) -> Result<Option<PrRef>> {
3503        let text = self.gh(&[
3504            "pr",
3505            "list",
3506            "--head",
3507            branch,
3508            "--base",
3509            base,
3510            "--state",
3511            "open",
3512            "--json",
3513            "number,url,title,baseRefName",
3514        ])?;
3515        pr_for_base(&text, branch, base)
3516    }
3517
3518    /// Every pull request opened from this branch, merged and closed ones
3519    /// included, because a commit is preserved by whichever one carries it and
3520    /// that is rarely the newest.
3521    fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
3522        self.branch_prs(branch, "all")
3523    }
3524
3525    fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
3526        let text = self.gh_try(&[
3527            "pr",
3528            "list",
3529            "--head",
3530            branch,
3531            "--state",
3532            state,
3533            "--json",
3534            "number,url,title",
3535        ]);
3536        serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
3537    }
3538
3539    /// Whether a number names an issue or a pull request.
3540    ///
3541    /// `gh issue view` happily returns a pull request when handed its number,
3542    /// so it cannot be used to tell them apart. The issues API carries both and
3543    /// marks a pull request with a `pull_request` key, which is definitive.
3544    pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
3545        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
3546        let text = self
3547            .gh(&[
3548                "api",
3549                &path,
3550                "--jq",
3551                "if .pull_request then \"pr\" else \"issue\" end",
3552            ])
3553            .map_err(|e| {
3554                spar_err!(
3555                    "no issue or pull request #{number} in this repository. {}",
3556                    e.last_line()
3557                )
3558            })?;
3559        match text.trim() {
3560            "pr" => Ok(ItemKind::Pr),
3561            "issue" => Ok(ItemKind::Issue),
3562            other => Err(spar_err!(
3563                "could not tell whether #{number} is an issue or a pull request (got {other:?})"
3564            )),
3565        }
3566    }
3567
3568    /// An open pull request that would close this issue, whoever opened it.
3569    ///
3570    /// spar's own branch naming is checked first because it is exact and cheap.
3571    /// Falling back to GitHub's own issue linkage is what lets spar pick up a
3572    /// pull request a person started on a branch named anything at all.
3573    ///
3574    /// An error rather than `None` when the question could not be asked. The
3575    /// two answers are not interchangeable: "no pull request exists" is the one
3576    /// that makes spar implement over the top of somebody's work and open a
3577    /// second pull request for the same issue.
3578    pub fn try_open_pr_for_issue(&self, issue: i64, base: &str) -> Result<Option<PrRef>> {
3579        let mine = self.pr_for_branch_failing_closed(&self.branch_for_issue(issue), base, issue)?;
3580        if let Some(pr) = mine {
3581            return Ok(Some(pr));
3582        }
3583        let listed = match self.gh(&[
3584            "pr",
3585            "list",
3586            "--state",
3587            "open",
3588            "--limit",
3589            &FETCH_CEILING.to_string(),
3590            "--json",
3591            "number,url,title,baseRefName,closingIssuesReferences",
3592        ]) {
3593            Ok(text) => linked_pr_rows(&text).ok_or_else(|| {
3594                spar_err!("could not read the pull request list while looking for #{issue}")
3595            })?,
3596            Err(e) => return self.unanswered_pr_lookup(issue, e).map(|()| None),
3597        };
3598        if listed.len() >= FETCH_CEILING {
3599            logwarn!(
3600                "#{issue}: the open pull request list filled its page of {FETCH_CEILING}, so a                  pull request past it cannot be seen from here."
3601            );
3602        }
3603        let default = self.default_branch(base);
3604        if !base.eq_ignore_ascii_case(&default) {
3605            // GitHub populates closingIssuesReferences only for pull requests
3606            // targeting the default branch, so on any other base the linkage is
3607            // silence rather than an answer.
3608            logwarn!(
3609                "#{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."
3610            );
3611        }
3612        Ok(pick_linked_pr(&listed, issue))
3613    }
3614
3615    /// spar's own branch, with a failed lookup kept as a failure.
3616    fn pr_for_branch_failing_closed(
3617        &self,
3618        branch: &str,
3619        base: &str,
3620        issue: i64,
3621    ) -> Result<Option<PrRef>> {
3622        match self.try_pr_for_branch(branch, base) {
3623            Ok(found) => Ok(found),
3624            Err(e) => self.unanswered_pr_lookup(issue, e).map(|()| None),
3625        }
3626    }
3627
3628    /// What to do when GitHub would not say whether a pull request exists.
3629    ///
3630    /// An error, because "no pull request exists" is the answer that makes spar
3631    /// implement from scratch and open a second pull request over somebody's
3632    /// work. The one exception is a repository gh cannot reach at all: there is
3633    /// no pull request to collide with when there is no GitHub repository in
3634    /// play, and every later write would fail anyway. That case is a warning and
3635    /// a `None`, so a run against a plain git remote behaves as it always did.
3636    fn unanswered_pr_lookup(&self, issue: i64, e: crate::error::SparError) -> Result<()> {
3637        if self.gh(&["repo", "view", "--json", "name"]).is_ok() {
3638            return Err(spar_err!(
3639                "could not check whether a pull request already exists for #{issue}: {}. \
3640                 Refusing to implement over the top of work that may already be open.",
3641                e.last_line()
3642            ));
3643        }
3644        logwarn!(
3645            "#{issue}: this repository could not be read through gh ({}), so spar cannot tell \
3646             whether a pull request already exists for it.",
3647            e.last_line()
3648        );
3649        Ok(())
3650    }
3651
3652    pub fn pr_view(&self, number: i64) -> Result<PrView> {
3653        let text = self.gh(&[
3654            "pr",
3655            "view",
3656            &number.to_string(),
3657            "--json",
3658            "number,url,title,body,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
3659        ])?;
3660        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
3661    }
3662
3663    fn try_pr_state(&self, number: i64) -> Result<String> {
3664        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "state"])?;
3665        serde_json::from_str::<Value>(text.trim())
3666            .map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))?
3667            .get("state")
3668            .and_then(Value::as_str)
3669            .map(str::to_string)
3670            .ok_or_else(|| spar_err!("PR #{number} did not include a state"))
3671    }
3672
3673    pub fn pr_state(&self, number: i64) -> String {
3674        self.try_pr_state(number).unwrap_or_default()
3675    }
3676
3677    /// The commit currently exposed as a pull request's head.
3678    pub fn pr_head_oid(&self, number: i64) -> Result<String> {
3679        let text = self.gh(&["pr", "view", &number.to_string(), "--json", "headRefOid"])?;
3680        let oid = serde_json::from_str::<Value>(&text)
3681            .ok()
3682            .and_then(|value| {
3683                value
3684                    .get("headRefOid")
3685                    .and_then(Value::as_str)
3686                    .map(str::trim)
3687                    .filter(|oid| !oid.is_empty())
3688                    .map(str::to_string)
3689            })
3690            .ok_or_else(|| spar_err!("could not read the head commit for PR #{number}"))?;
3691        Ok(oid)
3692    }
3693
3694    pub fn create_pr(
3695        &self,
3696        cwd: &Path,
3697        branch: &str,
3698        base: &str,
3699        title: &str,
3700        body: &str,
3701    ) -> Result<PrRef> {
3702        let title = self.record_failed_write(self.clean_title(title))?;
3703        let body = self.record_failed_write(self.clean(body))?;
3704        let mut argv = vec![
3705            "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
3706        ];
3707        if self.drafts != Drafts::Never {
3708            argv.push("--draft");
3709        }
3710        let created = self.gh_at(Some(cwd), &argv);
3711        let found = self.try_pr_for_branch(branch, base);
3712        self.record_write(reconcile_pr_creation(branch, created, found))
3713    }
3714
3715    pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
3716        let body = signed(&self.record_failed_write(self.clean(body))?);
3717        let comments = self.record_failed_write(self.try_issue_comments(number))?;
3718        if has_exact_comment(&comments, &body) {
3719            return Ok(());
3720        }
3721        let posted = self.gh(&["pr", "comment", &number.to_string(), "--body", &body]);
3722        let result = match posted {
3723            Ok(_) => Ok(()),
3724            Err(post_error) => {
3725                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3726            }
3727        };
3728        self.record_write(result)
3729    }
3730
3731    pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
3732        let body = signed(&self.record_failed_write(self.clean(body))?);
3733        let comments = self.record_failed_write(self.try_issue_comments(number))?;
3734        if has_exact_comment(&comments, &body) {
3735            return Ok(());
3736        }
3737        let posted = self.gh(&["issue", "comment", &number.to_string(), "--body", &body]);
3738        let result = match posted {
3739            Ok(_) => Ok(()),
3740            Err(post_error) => {
3741                reconcile_comment_post(number, &body, post_error, self.try_issue_comments(number))
3742            }
3743        };
3744        self.record_write(result)
3745    }
3746
3747    /// Comment, then close as not planned.
3748    ///
3749    /// Only ever called when both agents independently declined the issue: one
3750    /// agent's opinion is not enough to close somebody's report.
3751    pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
3752        self.comment_issue(number, body)?;
3753        let n = number.to_string();
3754        let closed = match self.gh(&["issue", "close", &n, "--reason", "not planned"]) {
3755            Ok(_) => Ok(()),
3756            // Older gh builds do not take --reason.
3757            Err(_) => self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
3758                spar_err!(
3759                    "commented on #{number} but could not close it: {}",
3760                    e.last_line()
3761                )
3762            }),
3763        };
3764        self.record_write(closed)
3765    }
3766
3767    /// Replace an issue body, refusing unless it is still byte for byte what
3768    /// the caller read and validating only the fragment spar inserted.
3769    ///
3770    /// The only place spar rewrites text somebody else wrote, so the check is
3771    /// the whole point: an edit computed from a body that has since moved would
3772    /// silently delete whatever moved it. The caller decides whether another
3773    /// attempt is safe for its workflow.
3774    ///
3775    /// Deliberately not through `clean_issue_body`. The body is mostly a
3776    /// person's own prose, and the scrub would rewrite their punctuation while
3777    /// the length budget could truncate the end of a long report. `inserted` is
3778    /// the only text here spar is answerable for, so it still passes through the
3779    /// style gate. The full body travels over stdin because a tracker can be far
3780    /// too long for one argument.
3781    pub fn edit_issue_body(
3782        &self,
3783        number: i64,
3784        expected: &str,
3785        body: &str,
3786        inserted: &str,
3787    ) -> Result<()> {
3788        let cleaned = self.record_failed_write(self.clean(inserted))?;
3789        if cleaned.trim() != inserted.trim() {
3790            return self.record_failed_write(Err(spar_err!(
3791                "the style gate rewrote {inserted:?} to {cleaned:?}, so it is not being inserted"
3792            )));
3793        }
3794        let current = self.record_failed_write(self.issue_body(number))?;
3795        if current != expected {
3796            return self.record_failed_write(Err(spar_err!(
3797                "the body of #{number} changed since it was read, so it was left alone rather \
3798                 than written over."
3799            )));
3800        }
3801        let edited = self.gh_stdin(
3802            &["issue", "edit", &number.to_string(), "--body-file", "-"],
3803            body,
3804        );
3805        let result = match edited {
3806            Ok(_) => Ok(()),
3807            Err(edit_error) => {
3808                reconcile_issue_edit(number, body, edit_error, self.issue_body(number))
3809            }
3810        };
3811        self.record_write(result)
3812    }
3813
3814    /// One issue's body, exactly as GitHub holds it.
3815    pub fn issue_body(&self, number: i64) -> Result<String> {
3816        #[derive(Deserialize)]
3817        struct Row {
3818            #[serde(default)]
3819            body: Option<String>,
3820        }
3821        let text = self.gh(&["issue", "view", &number.to_string(), "--json", "body"])?;
3822        let row: Row = serde_json::from_str(text.trim())
3823            .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
3824        Ok(row.body.unwrap_or_default())
3825    }
3826
3827    /// Every open issue with its title and body, in one call.
3828    ///
3829    /// For a screen that has to say something about each of twenty items before
3830    /// anything expensive happens. One call rather than one per issue.
3831    pub fn open_issue_rows(&self) -> Vec<Issue> {
3832        let text = self.gh_try(&[
3833            "issue",
3834            "list",
3835            "--state",
3836            "open",
3837            "--limit",
3838            &FETCH_CEILING.to_string(),
3839            "--json",
3840            "number,title,body,labels,state,stateReason,url",
3841        ]);
3842        serde_json::from_str::<Vec<Issue>>(text.trim()).unwrap_or_default()
3843    }
3844
3845    /// Every open pull request with its size, in one call.
3846    pub fn open_pr_rows(&self) -> Vec<PrRow> {
3847        let text = self.gh_try(&[
3848            "pr",
3849            "list",
3850            "--state",
3851            "open",
3852            "--limit",
3853            &FETCH_CEILING.to_string(),
3854            "--json",
3855            "number,title,changedFiles,additions,deletions",
3856        ]);
3857        serde_json::from_str::<Vec<PrRow>>(text.trim()).unwrap_or_default()
3858    }
3859
3860    pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
3861        self.create_issue_apart_from(title, body, None)
3862    }
3863
3864    pub fn create_issue_apart_from(
3865        &self,
3866        title: &str,
3867        body: &str,
3868        apart_from: Option<i64>,
3869    ) -> Result<String> {
3870        let title = self.record_failed_write(self.clean_title(title))?;
3871        let body = self.record_failed_write(self.clean_issue_body(body))?;
3872        let created = self.gh(&["issue", "create", "--title", &title, "--body", &body]);
3873        let result = match created {
3874            Ok(url) if issue_url_has_number(&url) => Ok(url.trim().to_string()),
3875            created => {
3876                let found = self.try_exact_issue_apart_from(&title, &body, apart_from);
3877                reconcile_issue_creation(&title, created, found)
3878            }
3879        };
3880        self.record_write(result)
3881    }
3882}
3883
3884/// An issue that already covers what spar was about to file.
3885#[derive(Debug, Clone)]
3886pub struct ExistingIssue {
3887    pub number: i64,
3888    pub url: String,
3889    pub title: String,
3890    pub body: String,
3891    pub open: bool,
3892}
3893
3894impl Repo {
3895    pub(crate) fn try_exact_issue_apart_from(
3896        &self,
3897        title: &str,
3898        body: &str,
3899        apart_from: Option<i64>,
3900    ) -> Result<Option<ExistingIssue>> {
3901        #[derive(Deserialize)]
3902        #[serde(rename_all = "camelCase")]
3903        struct Row {
3904            number: i64,
3905            #[serde(default)]
3906            title: String,
3907            #[serde(default)]
3908            url: String,
3909            #[serde(default)]
3910            body: Option<String>,
3911            #[serde(default)]
3912            state: String,
3913        }
3914
3915        let text = self.gh(&[
3916            "issue",
3917            "list",
3918            "--state",
3919            "all",
3920            "--limit",
3921            "100",
3922            "--json",
3923            "number,title,url,body,state,stateReason",
3924        ])?;
3925        let rows = serde_json::from_str::<Vec<Row>>(text.trim())
3926            .map_err(|e| spar_err!("unexpected issue list while verifying {title:?}: {e}"))?;
3927        Ok(rows
3928            .into_iter()
3929            .filter(|row| Some(row.number) != apart_from)
3930            .find(|row| row.title == title && row.body.as_deref().unwrap_or_default() == body)
3931            .map(|row| ExistingIssue {
3932                number: row.number,
3933                url: row.url,
3934                title: row.title,
3935                body: row.body.unwrap_or_default(),
3936                open: row.state.eq_ignore_ascii_case("open"),
3937            }))
3938    }
3939
3940    /// An issue that already describes this defect, however it was worded.
3941    ///
3942    /// Exact title matching let duplicates through: two agents, or two runs a
3943    /// week apart, never word one defect identically. A real run filed two
3944    /// duplicates that way, and each had to be closed by hand afterwards.
3945    /// Titles alone are too thin to match on, so this compares titles and
3946    /// bodies together.
3947    pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
3948        self.find_similar_issue_apart_from(title, body, None)
3949    }
3950
3951    /// The same search, with one issue that cannot be its own duplicate.
3952    ///
3953    /// A tracker's body quotes every item in its checklist, so searching for an
3954    /// item's words matches the tracker before it matches anything else. That
3955    /// would link an item to the issue it is written in.
3956    /// The search a tracker runs, which wants closed issues too: a checklist
3957    /// item matching a closed issue is a done item that wants a link.
3958    pub fn find_similar_issue_apart_from(
3959        &self,
3960        title: &str,
3961        body: &str,
3962        apart_from: Option<i64>,
3963    ) -> Option<ExistingIssue> {
3964        self.try_similar_issue(title, body, apart_from, true)
3965            .ok()
3966            .flatten()
3967    }
3968
3969    /// The search a filing runs, over open issues only.
3970    pub fn try_find_similar_issue_apart_from(
3971        &self,
3972        title: &str,
3973        body: &str,
3974        apart_from: Option<i64>,
3975    ) -> Result<Option<ExistingIssue>> {
3976        self.try_similar_issue(title, body, apart_from, false)
3977    }
3978
3979    /// The same search, preserving lookup failure for a caller about to write.
3980    fn try_similar_issue(
3981        &self,
3982        title: &str,
3983        body: &str,
3984        apart_from: Option<i64>,
3985        include_closed: bool,
3986    ) -> Result<Option<ExistingIssue>> {
3987        #[derive(Deserialize)]
3988        #[serde(rename_all = "camelCase")]
3989        struct Row {
3990            number: i64,
3991            #[serde(default)]
3992            title: String,
3993            #[serde(default)]
3994            url: String,
3995            #[serde(default)]
3996            body: String,
3997            #[serde(default)]
3998            state: String,
3999        }
4000        if title.trim().is_empty() {
4001            return Ok(None);
4002        }
4003        // Search on the title's own words: GitHub's index is the cheap way to
4004        // narrow the field before comparing properly.
4005        let query: String = title
4006            .chars()
4007            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4008            .take(120)
4009            .collect();
4010        // Open issues only when something is about to be filed. A fuzzy match
4011        // against a closed one is the case worth keeping rather than dropping:
4012        // a defect that was fixed, closed, and has come back is exactly the
4013        // finding a maintainer wants, and "#N covers it and is closed" wrote it
4014        // nowhere. An exact match is still checked against every state, since
4015        // that is a regression of one specific issue rather than a resemblance
4016        // to one, and a tracker still searches closed issues because a
4017        // checklist item matching one is a done item that wants a link.
4018        let state = if include_closed { "all" } else { "open" };
4019        let text = self.gh(&[
4020            "issue",
4021            "list",
4022            "--state",
4023            state,
4024            "--limit",
4025            "100",
4026            "--search",
4027            query.trim(),
4028            "--json",
4029            "number,title,url,body,state,stateReason",
4030        ])?;
4031        let rows: Vec<Row> = serde_json::from_str(text.trim())
4032            .map_err(|e| spar_err!("unexpected issue search for {title:?}: {e}"))?;
4033
4034        Ok(rows
4035            .into_iter()
4036            .filter(|row| Some(row.number) != apart_from)
4037            .find(|row| {
4038                row.title.trim().eq_ignore_ascii_case(title.trim())
4039                    || textsim::covers(title, body, &row.title, &row.body)
4040            })
4041            .map(|row| ExistingIssue {
4042                number: row.number,
4043                url: row.url,
4044                title: row.title,
4045                open: row.state.eq_ignore_ascii_case("open"),
4046                body: row.body,
4047            }))
4048    }
4049
4050    /// Avoid filing a duplicate when a follow-up already exists.
4051    pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
4052        #[derive(Deserialize)]
4053        struct Row {
4054            title: String,
4055            url: String,
4056        }
4057        let needle = title.trim().to_lowercase();
4058        if needle.is_empty() {
4059            return None;
4060        }
4061        // Quotes and newlines would be read as search syntax rather than text.
4062        let query: String = title
4063            .chars()
4064            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
4065            .take(120)
4066            .collect();
4067        let text = self.gh_try(&[
4068            "issue",
4069            "list",
4070            "--state",
4071            "all",
4072            "--limit",
4073            "100",
4074            "--search",
4075            query.trim(),
4076            "--json",
4077            "number,title,url",
4078        ]);
4079        serde_json::from_str::<Vec<Row>>(text.trim())
4080            .ok()?
4081            .into_iter()
4082            .find(|row| row.title.trim().to_lowercase() == needle)
4083            .map(|row| row.url)
4084    }
4085
4086    /// Squash merge, tolerating cleanup failures after a successful merge.
4087    ///
4088    /// Take a pull request out of draft, once the review has converged.
4089    ///
4090    /// Best effort for the remaining workflow. A failure does not discard the
4091    /// review or stop later independent work, but the final write summary
4092    /// reports it and the command returns non-zero.
4093    pub fn mark_ready(&self, number: i64) -> bool {
4094        match self.record_write(self.gh(&["pr", "ready", &number.to_string()])) {
4095            Ok(_) => true,
4096            Err(e) => {
4097                logdim!(
4098                    "PR #{number} is approved but could not be taken out of draft: {}",
4099                    e.last_line()
4100                );
4101                false
4102            }
4103        }
4104    }
4105
4106    /// `gh pr merge --delete-branch` exits non-zero when it cannot delete the
4107    /// local branch, which happens *after* the merge has already landed.
4108    /// Treating that as a failure reports work as lost when it is not.
4109    pub fn merge_pr(&self, number: i64) -> Result<()> {
4110        let n = number.to_string();
4111        let merged = match self.gh(&merge_pr_args(&n, None, true)) {
4112            Ok(_) => Ok(()),
4113            Err(e) => {
4114                if self.pr_state(number) == "MERGED" {
4115                    logdim!(
4116                        "PR #{number} merged; branch cleanup did not finish: {}",
4117                        e.last_line()
4118                    );
4119                    Ok(())
4120                } else {
4121                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4122                }
4123            }
4124        };
4125        self.record_write(merged)
4126    }
4127
4128    /// Squash merge only if the pull request still exposes the reviewed head.
4129    pub fn merge_pr_at_head(
4130        &self,
4131        number: i64,
4132        expected_head: &str,
4133        delete_branch: bool,
4134    ) -> Result<()> {
4135        let n = number.to_string();
4136        let merged = match self.gh(&merge_pr_args(&n, Some(expected_head), delete_branch)) {
4137            Ok(_) => Ok(()),
4138            Err(e) => {
4139                if self.pr_state(number) == "MERGED" {
4140                    logdim!(
4141                        "PR #{number} merged; branch cleanup did not finish: {}",
4142                        e.last_line()
4143                    );
4144                    Ok(())
4145                } else {
4146                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
4147                }
4148            }
4149        };
4150        self.record_write(merged)
4151    }
4152
4153    // -- follow-ups -------------------------------------------------------
4154
4155    /// The queue of follow-ups recorded locally rather than filed, which
4156    /// `spar followup` works.
4157    pub fn followups_path(&self) -> PathBuf {
4158        self.root.join(STATE_DIR).join("followups.md")
4159    }
4160
4161    /// What `spar followup` already dealt with, kept beside the queue.
4162    ///
4163    /// Two jobs. It is what stops `append_local_followup` re-recording a
4164    /// follow-up whose entry has since left the queue, which would otherwise
4165    /// turn the file into a ring buffer of things already filed. And it keeps
4166    /// the text of an entry a screening pass ruled stale, so a wrong verdict
4167    /// costs a re-read rather than the only copy of a real defect.
4168    pub fn worked_followups_path(&self) -> PathBuf {
4169        self.root.join(STATE_DIR).join("followups.done.md")
4170    }
4171
4172    /// What `spar checkin` has already answered on one pull request or issue.
4173    /// Where what a run spent is kept.
4174    pub fn spend_path(&self) -> PathBuf {
4175        self.root.join(STATE_DIR).join("state").join("spend.json")
4176    }
4177
4178    /// Append this run's calls to the record.
4179    ///
4180    /// Appended rather than replaced, so a resumed pull request adds to the
4181    /// count of what it has cost so far rather than starting it again, and so
4182    /// anybody charting this has a file to chart.
4183    pub fn record_spend(&self, calls: &[crate::spend::Spent]) -> Result<()> {
4184        if calls.is_empty() {
4185            return Ok(());
4186        }
4187        let mut all: Vec<crate::spend::Spent> = std::fs::read_to_string(self.spend_path())
4188            .ok()
4189            .and_then(|text| serde_json::from_str(&text).ok())
4190            .unwrap_or_default();
4191        all.extend(calls.iter().cloned());
4192        if let Some(parent) = self.spend_path().parent() {
4193            let _ = std::fs::create_dir_all(parent);
4194        }
4195        write_json_atomic(&self.spend_path(), &all)
4196    }
4197
4198    /// Who wrote the first draft of each issue, by number.
4199    ///
4200    /// Custody alternates within a pull request and the record of it lives in
4201    /// that pull request's state. This is the other question, which nothing
4202    /// answered: over a queue, which model authored what.
4203    pub fn implementors_path(&self) -> PathBuf {
4204        self.root
4205            .join(STATE_DIR)
4206            .join("state")
4207            .join("implementors.json")
4208    }
4209
4210    pub fn read_implementors(&self) -> BTreeMap<i64, String> {
4211        std::fs::read_to_string(self.implementors_path())
4212            .ok()
4213            .and_then(|text| serde_json::from_str(&text).ok())
4214            .unwrap_or_default()
4215    }
4216
4217    /// Best effort: this is a record for a person, not something the loop reads
4218    /// back, so a failure to write it is not a reason to stop.
4219    pub fn remember_implementor(&self, issue: i64, agent: &str) {
4220        let mut known = self.read_implementors();
4221        known.insert(issue, agent.to_string());
4222        if let Some(parent) = self.implementors_path().parent() {
4223            let _ = std::fs::create_dir_all(parent);
4224        }
4225        if let Err(e) = write_json_atomic(&self.implementors_path(), &known) {
4226            logdim!("could not record who implemented #{issue}: {e}");
4227        }
4228    }
4229
4230    /// Where the issues triage disagreed about are remembered.
4231    ///
4232    /// One file rather than one per issue: it is read once per run, before any
4233    /// triage call, to decide what not to ask about again.
4234    pub fn contested_path(&self) -> PathBuf {
4235        self.root
4236            .join(STATE_DIR)
4237            .join("state")
4238            .join("contested.json")
4239    }
4240
4241    /// The issues a person has been asked to decide, by number.
4242    pub fn read_contested(&self) -> BTreeMap<i64, Contested> {
4243        std::fs::read_to_string(self.contested_path())
4244            .ok()
4245            .and_then(|text| serde_json::from_str(&text).ok())
4246            .unwrap_or_default()
4247    }
4248
4249    /// Remember an issue triage could not agree on.
4250    ///
4251    /// Best effort: a run that cannot write this asks again next time, which is
4252    /// what it did before this existed.
4253    pub fn remember_contested(&self, items: &[ContestedItem]) {
4254        if items.is_empty() {
4255            return;
4256        }
4257        let mut known = self.read_contested();
4258        for item in items {
4259            known.insert(
4260                item.issue,
4261                Contested {
4262                    title: item.title.clone(),
4263                    positions: item.positions.clone(),
4264                    reasons: item.reasons.clone(),
4265                },
4266            );
4267        }
4268        if let Some(parent) = self.contested_path().parent() {
4269            let _ = std::fs::create_dir_all(parent);
4270        }
4271        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4272            logdim!("could not record the contested issues: {e}");
4273        }
4274    }
4275
4276    /// Ask about them again, because a person has acted on them.
4277    pub fn forget_contested(&self, numbers: &[i64]) {
4278        let mut known = self.read_contested();
4279        let before = known.len();
4280        known.retain(|number, _| !numbers.contains(number));
4281        if known.len() == before {
4282            return;
4283        }
4284        if let Err(e) = write_json_atomic(&self.contested_path(), &known) {
4285            logdim!("could not update the contested issues: {e}");
4286        }
4287    }
4288
4289    pub fn checkin_state_path(&self, number: i64) -> PathBuf {
4290        self.root
4291            .join(STATE_DIR)
4292            .join("state")
4293            .join(format!("checkin-{number}.json"))
4294    }
4295
4296    /// Append a follow-up to a local note instead of the tracker.
4297    ///
4298    /// Deduplicated on the title, matching the issue path. The body arrives
4299    /// with its provenance already stamped by the caller, so nothing is added
4300    /// here.
4301    ///
4302    /// A write that did not happen is reported as such rather than as a
4303    /// duplicate: the caller settles the point on the strength of this answer,
4304    /// and settling it on a failed write is how a real defect is lost.
4305    ///
4306    /// Both files are checked, because `spar followup` removes an entry from
4307    /// the queue once it has filed it. Checking only the queue would let the
4308    /// next run that rediscovers the same defect append it again, on top of the
4309    /// issue that now exists for it.
4310    pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
4311        let path = self.followups_path();
4312        let heading = format!("## {}", title.trim());
4313        for seen in [&path, &self.worked_followups_path()] {
4314            if let Ok(existing) = std::fs::read_to_string(seen) {
4315                if has_heading(&existing, title) {
4316                    logdim!("follow-up already noted: {title}");
4317                    return Followup::Covered(format!("note: {}", title.trim()));
4318                }
4319            }
4320        }
4321        if let Some(parent) = path.parent() {
4322            let _ = std::fs::create_dir_all(parent);
4323        }
4324        use std::io::Write;
4325        // The caller already stamped the provenance into the body. Adding
4326        // "From #N." here as well printed it twice, in two different wordings.
4327        //
4328        // The marker above the heading is what makes the entry boundary
4329        // unambiguous to the parser, since the body's own sections are written
4330        // at the same heading level as the title.
4331        let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
4332        match std::fs::OpenOptions::new()
4333            .create(true)
4334            .append(true)
4335            .open(&path)
4336        {
4337            Ok(mut file) => match file.write_all(entry.as_bytes()) {
4338                Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
4339                Err(e) => {
4340                    logdim!("could not write {}: {e}", path.display());
4341                    Followup::Failed
4342                }
4343            },
4344            Err(e) => {
4345                logdim!("could not write {}: {e}", path.display());
4346                Followup::Failed
4347            }
4348        }
4349    }
4350
4351    /// Record what `spar followup` did with an entry, and why.
4352    ///
4353    /// Best effort: an archive that could not be written is not a reason to
4354    /// stop, since the entry has already been filed or ruled on.
4355    pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
4356        let path = self.worked_followups_path();
4357        if let Some(parent) = path.parent() {
4358            let _ = std::fs::create_dir_all(parent);
4359        }
4360        use std::io::Write;
4361        let entry = format!(
4362            "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
4363            title.trim(),
4364            body.trim()
4365        );
4366        if let Ok(mut file) = std::fs::OpenOptions::new()
4367            .create(true)
4368            .append(true)
4369            .open(&path)
4370        {
4371            let _ = file.write_all(entry.as_bytes());
4372        }
4373    }
4374
4375    // -- resumable state --------------------------------------------------
4376    //
4377    // Custody cannot be read from GitHub authorship: every agent commits and
4378    // comments as the same git identity, so `author` is always the human who
4379    // ran spar. State is kept on disk by default and can additionally travel in
4380    // a PR comment, which is what lets a run be resumed from another machine.
4381
4382    /// Where a comment spar produced but did not post is kept.
4383    pub fn pending_comment_path(&self, number: i64) -> PathBuf {
4384        self.root
4385            .join(STATE_DIR)
4386            .join("reviews")
4387            .join(format!("pr-{number}.md"))
4388    }
4389
4390    /// Keep a comment spar decided not to post.
4391    ///
4392    /// A dry run that prints and forgets means agreeing with what you read
4393    /// costs a second full review. Saving it makes the whole point of reading
4394    /// it first: look, edit if you like, then post what you already paid for.
4395    pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
4396        let path = self.pending_comment_path(number);
4397        if let Some(parent) = path.parent() {
4398            std::fs::create_dir_all(parent)
4399                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
4400        }
4401        std::fs::write(&path, text)
4402            .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
4403        Ok(path)
4404    }
4405
4406    pub fn read_pending_comment(&self, number: i64) -> Option<String> {
4407        std::fs::read_to_string(self.pending_comment_path(number)).ok()
4408    }
4409
4410    pub fn state_path(&self, number: i64) -> PathBuf {
4411        self.root
4412            .join(STATE_DIR)
4413            .join("state")
4414            .join(format!("pr-{number}.json"))
4415    }
4416
4417    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
4418        let path = self.state_path(number);
4419        let text = std::fs::read_to_string(&path).ok()?;
4420        match serde_json::from_str(&text) {
4421            Ok(state) => Some(state),
4422            Err(_) => {
4423                logdim!("could not read {}, starting fresh", path.display());
4424                None
4425            }
4426        }
4427    }
4428
4429    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
4430        if let Some(local) = self.read_local_state(pr.number) {
4431            return Some(local);
4432        }
4433        if self.state_store.writes_pr() {
4434            return self.read_pr_state(pr.number);
4435        }
4436        None
4437    }
4438
4439    pub(crate) fn read_state_for_head(
4440        &self,
4441        pr: &PrView,
4442        actual_head: &str,
4443    ) -> Option<PersistedState> {
4444        let local = self
4445            .state_store
4446            .writes_local()
4447            .then(|| self.read_local_state(pr.number))
4448            .flatten();
4449        let remote = self
4450            .state_store
4451            .writes_pr()
4452            .then(|| self.read_pr_state(pr.number))
4453            .flatten();
4454        let candidates: Vec<PersistedState> = [local, remote].into_iter().flatten().collect();
4455        if let Some(checkpoint) = candidates.iter().map(|state| state.checkpoint).max() {
4456            self.remember_checkpoint(pr.number, checkpoint);
4457        }
4458        choose_state_for_head(candidates, actual_head)
4459    }
4460
4461    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
4462        self.try_read_pr_state(number).ok().flatten()
4463    }
4464
4465    fn try_read_pr_state(&self, number: i64) -> Result<Option<PersistedState>> {
4466        for (_, body) in self.try_state_comments(number)?.into_iter().rev() {
4467            if let Some(state) = parse_state_comment(&body) {
4468                return Ok(Some(state));
4469            }
4470        }
4471        Ok(None)
4472    }
4473
4474    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4475        let remote_state = if self.state_store.writes_pr() {
4476            self.try_read_pr_state(number)
4477        } else {
4478            Ok(None)
4479        };
4480        self.write_state_after_remote_read(number, state, remote_state)
4481    }
4482
4483    fn write_state_after_remote_read(
4484        &self,
4485        number: i64,
4486        state: &PersistedState,
4487        remote_state: Result<Option<PersistedState>>,
4488    ) -> Result<()> {
4489        let remote_checkpoint = if self.state_store.writes_pr() {
4490            self.record_failed_write(remote_state)?
4491                .map(|saved| saved.checkpoint)
4492                .unwrap_or_default()
4493        } else {
4494            0
4495        };
4496        let local_checkpoint = self
4497            .state_store
4498            .writes_local()
4499            .then(|| self.read_local_state(number))
4500            .flatten()
4501            .map(|saved| saved.checkpoint)
4502            .unwrap_or_default();
4503        let mut stamped = state.clone();
4504        stamped.checkpoint = state
4505            .checkpoint
4506            .max(local_checkpoint)
4507            .max(remote_checkpoint)
4508            .max(self.remembered_checkpoint(number))
4509            .saturating_add(1);
4510        self.remember_checkpoint(number, stamped.checkpoint);
4511        if self.state_store.writes_local() {
4512            write_json_atomic(&self.state_path(number), &stamped)?;
4513        }
4514        if self.state_store.writes_pr() {
4515            self.write_pr_state(number, &stamped)?;
4516        }
4517        Ok(())
4518    }
4519
4520    fn remembered_checkpoint(&self, number: i64) -> u64 {
4521        self.checkpoints
4522            .lock()
4523            .unwrap_or_else(std::sync::PoisonError::into_inner)
4524            .get(&number)
4525            .copied()
4526            .unwrap_or_default()
4527    }
4528
4529    fn remember_checkpoint(&self, number: i64, checkpoint: u64) {
4530        let mut checkpoints = self
4531            .checkpoints
4532            .lock()
4533            .unwrap_or_else(std::sync::PoisonError::into_inner);
4534        let saved = checkpoints.entry(number).or_default();
4535        *saved = (*saved).max(checkpoint);
4536    }
4537
4538    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
4539        // Not run through clean(): this is structured data, and scrubbing would
4540        // corrupt refutation text stored in the ledger. It sits inside an
4541        // unclosed HTML comment so GitHub renders it as nothing.
4542        let serialized = self.record_failed_write(serde_json::to_string_pretty(state))?;
4543        let body = format!("{STATE_MARKER}\n{}\n-->", serialized);
4544        let comment_id = self.record_failed_write(self.try_state_comment_id(number))?;
4545        if let Some(id) = comment_id {
4546            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4547            let field = format!("body={body}");
4548            let written = self
4549                .gh(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"])
4550                .map(|_| ());
4551            return self.record_write(written);
4552        }
4553        let written = self
4554            .gh(&["pr", "comment", &number.to_string(), "--body", &body])
4555            .map(|_| ());
4556        self.record_write(written)
4557    }
4558
4559    /// Top level comments. Works for issues and pull requests alike, because
4560    /// GitHub serves both from the issues endpoint.
4561    ///
4562    /// Nothing when they cannot be read, which suits a reader that is going to
4563    /// go on regardless. A caller deciding whether it has already written here
4564    /// wants `try_issue_comments`, since for that one no comments and no answer
4565    /// are opposite answers.
4566    pub fn issue_comments(&self, number: i64) -> Vec<Value> {
4567        self.try_issue_comments(number).unwrap_or_default()
4568    }
4569
4570    pub fn try_issue_comments(&self, number: i64) -> Result<Vec<Value>> {
4571        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
4572        try_parse_comment_pages(&self.gh(&["api", "--paginate", &path])?)
4573    }
4574
4575    /// spar's own state comments on a pull request, newest last.
4576    ///
4577    /// Three tests, because the answer decides what gets patched over and what
4578    /// gets deleted. A comment matching the marker loosely, which is what
4579    /// somebody asking "why is there a `<!-- spar:state` block on this PR?"
4580    /// writes, is named in the log and left exactly where it is.
4581    fn try_state_comments(&self, number: i64) -> Result<Vec<(i64, String)>> {
4582        let viewer = self.viewer_login()?.to_string();
4583        let mut mine = Vec::new();
4584        let mut theirs = Vec::new();
4585        for comment in self.try_issue_comments(number)? {
4586            let Some(body) = comment.get("body").and_then(Value::as_str) else {
4587                continue;
4588            };
4589            if !body.contains("spar:state") {
4590                continue;
4591            }
4592            let Some(id) = comment.get("id").and_then(Value::as_i64) else {
4593                continue;
4594            };
4595            let author = comment
4596                .get("user")
4597                .and_then(|u| u.get("login"))
4598                .and_then(Value::as_str)
4599                .unwrap_or_default();
4600            if is_state_comment(body, author, &viewer) {
4601                mine.push((id, body.to_string()));
4602            } else {
4603                theirs.push(format!("@{author}"));
4604            }
4605        }
4606        if !theirs.is_empty() {
4607            logdim!(
4608                "left {} comment(s) on #{number} that mention spar:state alone: {}. Only spar's \
4609                 own state block is patched or deleted.",
4610                theirs.len(),
4611                theirs.join(", ")
4612            );
4613        }
4614        Ok(mine)
4615    }
4616
4617    fn try_state_comment_id(&self, number: i64) -> Result<Option<i64>> {
4618        Ok(self.try_state_comments(number)?.last().map(|(id, _)| *id))
4619    }
4620
4621    /// Drop state once the PR is finished and there is nothing to resume.
4622    pub fn clear_state(&self, number: i64) {
4623        let path = self.state_path(number);
4624        let _ = std::fs::remove_file(&path);
4625        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
4626    }
4627
4628    // -- housekeeping -----------------------------------------------------
4629
4630    /// Remove state files whose PR is merged or closed.
4631    pub fn prune_state(&self) -> Vec<String> {
4632        let base = self.root.join(STATE_DIR).join("state");
4633        let Ok(entries) = std::fs::read_dir(&base) else {
4634            return Vec::new();
4635        };
4636        let mut names: Vec<String> = entries
4637            .flatten()
4638            .filter_map(|e| e.file_name().to_str().map(str::to_string))
4639            .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
4640            .collect();
4641        names.sort();
4642
4643        let mut removed = Vec::new();
4644        for name in names {
4645            let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
4646                continue;
4647            };
4648            if is_finished(&self.pr_state(number)) {
4649                let _ = std::fs::remove_file(base.join(&name));
4650                removed.push(format!("state {name}"));
4651            }
4652        }
4653        removed
4654    }
4655
4656    /// Delete state comments from PRs that are finished.
4657    ///
4658    /// Open PRs are left alone: their state may still be live.
4659    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
4660        #[derive(Deserialize)]
4661        struct Row {
4662            number: i64,
4663        }
4664        let numbers = match numbers {
4665            Some(numbers) => numbers,
4666            None => {
4667                let listed: Result<Vec<i64>> = (|| {
4668                    let text = self.gh(&[
4669                        "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
4670                    ])?;
4671                    let rows = serde_json::from_str::<Vec<Row>>(text.trim())
4672                        .map_err(|e| spar_err!("unexpected pull request list: {e}"))?;
4673                    Ok(rows.into_iter().map(|row| row.number).collect())
4674                })();
4675                match self.record_failed_write(listed) {
4676                    Ok(numbers) => numbers,
4677                    Err(e) => {
4678                        logdim!("could not inspect pull requests for state cleanup: {e}");
4679                        return Vec::new();
4680                    }
4681                }
4682            }
4683        };
4684
4685        let mut removed = Vec::new();
4686        for number in numbers {
4687            let state = match self.record_failed_write(self.try_pr_state(number)) {
4688                Ok(state) => state,
4689                Err(e) => {
4690                    logdim!("could not inspect PR #{number} for state cleanup: {e}");
4691                    continue;
4692                }
4693            };
4694            if !is_finished(&state) {
4695                continue;
4696            }
4697            let comments = match self.record_failed_write(self.try_state_comments(number)) {
4698                Ok(comments) => comments,
4699                Err(e) => {
4700                    logdim!("could not inspect state comments on PR #{number}: {e}");
4701                    continue;
4702                }
4703            };
4704            for (id, _) in comments {
4705                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
4706                let deleted = self
4707                    .gh(&["api", "-X", "DELETE", &path, "--silent"])
4708                    .map(|_| ());
4709                match self.record_write(deleted) {
4710                    Ok(()) => removed.push(format!("state comment on PR #{number}")),
4711                    Err(e) => logdim!("could not remove state comment on PR #{number}: {e}"),
4712                }
4713            }
4714        }
4715        removed
4716    }
4717
4718    /// Drop worktrees whose PR is finished, then the branches they left behind.
4719    ///
4720    /// With auto_merge off, which is the default, a run ends at "approved", so
4721    /// nothing would ever clean these up on its own and they accumulate one per
4722    /// run. A stranded worktree also holds its branch checked out, which makes
4723    /// a later `gh pr merge --delete-branch` fail to clean up.
4724    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
4725        let base = self.root.join(WORKTREE_DIR);
4726        let mut removed = Vec::new();
4727        let known = self.known_branches();
4728
4729        if let Ok(entries) = std::fs::read_dir(&base) {
4730            let mut names: Vec<String> = entries
4731                .flatten()
4732                .filter(|e| e.path().is_dir())
4733                .filter_map(|e| e.file_name().to_str().map(str::to_string))
4734                .collect();
4735            names.sort();
4736
4737            for name in names {
4738                // A review worktree is detached and owns no branch, so it is
4739                // tied to the pull request only by its directory name.
4740                if let Some(rest) = name.strip_prefix("review-") {
4741                    let number: i64 = rest.parse().unwrap_or(-1);
4742                    if !(force_all || is_finished(&self.pr_state(number))) {
4743                        continue;
4744                    }
4745                    let path = base.join(&name);
4746                    if force_all {
4747                        let owned = self.worktree_belongs_to_repo(&path).and_then(|belongs| {
4748                            if !belongs {
4749                                return Ok(false);
4750                            }
4751                            let local_ref = review_ref(number);
4752                            if !self.exact_ref_exists_checked(&self.root, &local_ref)? {
4753                                return Ok(false);
4754                            }
4755                            let head = self.head_oid_checked(&path)?;
4756                            let recorded = self
4757                                .git_at(Some(&self.root), &["rev-parse", "--verify", &local_ref])?
4758                                .trim()
4759                                .to_string();
4760                            Ok(head == recorded)
4761                        });
4762                        match owned {
4763                            Ok(true) => {}
4764                            Ok(false) => {
4765                                logdim!(
4766                                    "kept {} because no matching SPAR review reference proves \
4767                                     ownership",
4768                                    path.display()
4769                                );
4770                                continue;
4771                            }
4772                            Err(e) => {
4773                                logdim!(
4774                                    "kept {} because review ownership could not be verified: {}",
4775                                    path.display(),
4776                                    e.last_line()
4777                                );
4778                                continue;
4779                            }
4780                        }
4781                    } else {
4782                        if let Err(e) = self.refuse_review_worktree_changes(number) {
4783                            logdim!(
4784                                "kept {} because its review state could not be verified as \
4785                                 disposable: {}",
4786                                path.display(),
4787                                e.last_line()
4788                            );
4789                            continue;
4790                        }
4791                    }
4792                    if force_all {
4793                        if self.remove_worktree_at_force(&path) {
4794                            self.git_try(&["update-ref", "-d", &review_ref(number)]);
4795                        }
4796                    } else {
4797                        self.release_review_worktree(number);
4798                    }
4799                    if !path.exists() {
4800                        removed.push(name);
4801                    }
4802                    continue;
4803                }
4804                let branch = format!("{}{name}", self.branch_prefix);
4805                if !(force_all || self.worktree_is_done(&branch)) {
4806                    continue;
4807                }
4808                if !known.contains_key(&branch) {
4809                    logdim!("kept {branch} because it has no branch record");
4810                    continue;
4811                }
4812                let path = base.join(&name);
4813                if !force_all {
4814                    match self.has_recoverable_work(&path, Disposable::BuildOutput) {
4815                        Ok(true) => {
4816                            logdim!(
4817                                "kept {} because it contains uncommitted changes or ignored files",
4818                                path.display()
4819                            );
4820                            continue;
4821                        }
4822                        Err(e) => {
4823                            logdim!(
4824                                "kept {} because its Git state could not be checked: {}",
4825                                path.display(),
4826                                e.last_line()
4827                            );
4828                            continue;
4829                        }
4830                        Ok(false) => {}
4831                    }
4832                    match self.branch_deletion_is_safe(&branch) {
4833                        Ok(true) => {}
4834                        Ok(false) => {
4835                            logdim!(
4836                                "kept {branch} because no surviving ref preserves its tip or \
4837                                 reflog-only commits"
4838                            );
4839                            continue;
4840                        }
4841                        Err(e) => {
4842                            logdim!(
4843                                "kept {branch} because preservation could not be verified: {}",
4844                                e.last_line()
4845                            );
4846                            continue;
4847                        }
4848                    }
4849                }
4850                let removed_worktree = if force_all {
4851                    self.remove_worktree_at_force(&path)
4852                } else {
4853                    match self.remove_worktree_at(&path) {
4854                        Ok(removed) => removed,
4855                        Err(error) => {
4856                            logdim!(
4857                                "kept {branch} and {} because removal did not reach a confirmed \
4858                                 quiet point: {}",
4859                                path.display(),
4860                                error.last_line()
4861                            );
4862                            false
4863                        }
4864                    }
4865                };
4866                if !removed_worktree {
4867                    continue;
4868                }
4869                if force_all {
4870                    self.git_try(&["branch", "-D", &branch]);
4871                    self.forget_branch(&branch);
4872                } else {
4873                    match self.delete_branch_if_safe(&branch) {
4874                        Ok(true) => self.forget_branch(&branch),
4875                        Ok(false) => logdim!(
4876                            "kept {branch} because its tip or reflog changed before deletion"
4877                        ),
4878                        Err(error) => logdim!(
4879                            "kept {branch} because deletion safety could not be rechecked: {}",
4880                            error.last_line()
4881                        ),
4882                    }
4883                }
4884                removed.push(name);
4885            }
4886        }
4887        removed.extend(self.prune_branches(force_all));
4888        removed
4889    }
4890
4891    /// Delete leftover branches spar created whose worktree is already gone.
4892    ///
4893    /// Deletion is driven by the ledger of branches spar actually created, not
4894    /// by a name pattern. Names default to `issue-N`, which is exactly what a
4895    /// person would call a branch themselves, so a name alone can never
4896    /// establish ownership. This is the data loss guard.
4897    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
4898        let known = self.known_branches();
4899        let branches: Vec<String> = known.keys().cloned().collect();
4900        if branches.is_empty() {
4901            return Vec::new();
4902        }
4903
4904        let checked_out: Vec<String> = self
4905            .git_try(&["worktree", "list", "--porcelain"])
4906            .lines()
4907            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
4908            .collect();
4909
4910        // %(refname:short) is ambiguous when a tag shares the branch name (it
4911        // yields "heads/..."), so take the full ref and strip it here.
4912        let existing: Vec<String> = self
4913            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
4914            .lines()
4915            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
4916            .collect();
4917
4918        let mut removed = Vec::new();
4919        for branch in branches {
4920            if !existing.contains(&branch) {
4921                self.forget_branch(&branch); // already gone, drop the record
4922                continue;
4923            }
4924            if checked_out.contains(&branch) {
4925                continue;
4926            }
4927            if !(force_all || self.worktree_is_done(&branch)) {
4928                continue;
4929            }
4930            if !force_all {
4931                let Some(_record) = known.get(&branch) else {
4932                    continue;
4933                };
4934                match self.branch_deletion_is_safe(&branch) {
4935                    Ok(true) => {}
4936                    Ok(false) => {
4937                        logdim!(
4938                            "kept {branch} because no surviving ref preserves its tip or \
4939                             reflog-only commits"
4940                        );
4941                        continue;
4942                    }
4943                    Err(e) => {
4944                        logdim!(
4945                            "kept {branch} because preservation could not be verified: {}",
4946                            e.last_line()
4947                        );
4948                        continue;
4949                    }
4950                }
4951            }
4952            let deleted = if force_all {
4953                self.git(&["branch", "-D", &branch]).map(|_| true)
4954            } else {
4955                self.delete_branch_if_safe(&branch)
4956            };
4957            match deleted {
4958                Ok(true) => {
4959                    self.forget_branch(&branch);
4960                    removed.push(format!("branch {branch}"));
4961                }
4962                Ok(false) => {
4963                    logdim!("kept {branch} because its tip or reflog changed before deletion");
4964                }
4965                Err(e) => {
4966                    // A branch that silently survives pruning looks like a spar
4967                    // bug, so the name and git's own reason have to be said.
4968                    logdim!("could not delete {branch}: {}", e.last_line());
4969                }
4970            }
4971        }
4972        removed
4973    }
4974
4975    /// True when the PR behind this branch is merged or closed.
4976    fn worktree_is_done(&self, branch: &str) -> bool {
4977        #[derive(Deserialize)]
4978        struct Row {
4979            state: String,
4980        }
4981        let entry = branch
4982            .strip_prefix(self.branch_prefix.as_str())
4983            .unwrap_or(branch);
4984        if let Some(rest) = entry.strip_prefix("pr-") {
4985            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
4986        }
4987        // A split part is the same shape as an issue branch: one branch, whose
4988        // pull requests say whether it is finished. Without it here, a part
4989        // branch is one nothing but `clean --all` would ever remove.
4990        if entry.starts_with("issue-") || entry.starts_with("split-") {
4991            let text = self.gh_try(&[
4992                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
4993            ]);
4994            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
4995            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
4996        }
4997        false
4998    }
4999}
5000
5001// ---------------------------------------------------------------------------
5002// Free helpers
5003// ---------------------------------------------------------------------------
5004
5005/// Take a read-only probe again before treating its failure as evidence.
5006///
5007/// Every probe is a Git invocation under a thirty second cap plus a walk over a
5008/// tree an agent may still be building in. A probe that fails proves nothing
5009/// about the worktree, and there is a real difference between "the tree moved"
5010/// and "the question could not be asked just then", which the caller turns into
5011/// the same run-ending error.
5012pub(crate) fn probe_again_on_failure<T>(probe: impl Fn() -> Result<T>) -> Result<T> {
5013    match probe() {
5014        Ok(value) => Ok(value),
5015        Err(first) => {
5016            logdim!("retrying a worktree probe after: {}", first.last_line());
5017            probe()
5018        }
5019    }
5020}
5021
5022/// Read attribute files without asking Git to inspect working-tree content.
5023///
5024/// A newly written attribute can select a clean or smudge filter. It must be
5025/// detected before a post-call status, diff, or add command has a chance to run
5026/// that filter in the parent process.
5027pub(crate) fn attribute_state(cwd: &Path) -> Result<AttributeState> {
5028    let root = std::fs::canonicalize(cwd)
5029        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5030    let mut files = BTreeMap::new();
5031    let mut visited = BTreeSet::new();
5032    collect_attribute_files(&root, &root, Path::new(""), &mut visited, &mut files)?;
5033    Ok(AttributeState { files })
5034}
5035
5036fn collect_attribute_files(
5037    root: &Path,
5038    repository: &Path,
5039    prefix: &Path,
5040    visited: &mut BTreeSet<PathBuf>,
5041    files: &mut BTreeMap<PathBuf, [u8; 32]>,
5042) -> Result<()> {
5043    let canonical = std::fs::canonicalize(repository)
5044        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5045    if !visited.insert(canonical) {
5046        bail!("submodule recursion revisited {}", repository.display());
5047    }
5048    let entries = index_entries(repository)?;
5049    let mut paths: BTreeSet<PathBuf> = entries
5050        .iter()
5051        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5052        .map(|entry| entry.path.clone())
5053        .collect();
5054    // Ignored on purpose. A `.gitattributes` under build output can only select
5055    // filters for paths that are themselves ignored, and no ignored path is
5056    // ever staged or compared as tracked content, so it cannot reach a managed
5057    // commit. Capturing it meant `cargo package` or `npm ci` writing one into
5058    // its own output ended a read-only call outright.
5059    let untracked = run_git_bytes(
5060        repository,
5061        &[
5062            "ls-files",
5063            "--others",
5064            "--exclude-standard",
5065            "-z",
5066            "--",
5067            ".gitattributes",
5068            ":(glob)**/.gitattributes",
5069        ],
5070    )?;
5071    if !untracked.is_empty() && !untracked.ends_with(&[0]) {
5072        bail!(
5073            "git returned an unterminated attribute-file listing for {}",
5074            repository.display()
5075        );
5076    }
5077    for raw in untracked
5078        .split(|byte| *byte == 0)
5079        .filter(|record| !record.is_empty())
5080    {
5081        paths.insert(safe_git_path(raw, "attribute")?);
5082    }
5083    for path in paths {
5084        let from_root = prefix.join(&path);
5085        let state = attribute_file_fingerprint(&root.join(&from_root))?;
5086        files.insert(from_root, state);
5087    }
5088    for entry in entries.into_iter().filter(|entry| entry.mode == "160000") {
5089        let Some(submodule) = initialized_submodule(repository, &entry.path)? else {
5090            continue;
5091        };
5092        collect_attribute_files(root, &submodule, &prefix.join(&entry.path), visited, files)?;
5093    }
5094    Ok(())
5095}
5096
5097/// Leave a visible, untracked reason ordinary cleanup can detect on a later
5098/// run even when Git status normalizes the original working-file change away.
5099pub(crate) fn uncertain_worktree_change(
5100    cwd: &Path,
5101    message: impl Into<String>,
5102) -> crate::error::SparError {
5103    let message = message.into();
5104    let marker = write_recovery_marker(cwd, &message);
5105    let note = match marker {
5106        Ok(path) => format!(" Recovery marker: {}.", path.display()),
5107        Err(e) => format!(
5108            " A recovery marker could not be written: {}.",
5109            e.last_line()
5110        ),
5111    };
5112    crate::error::SparError::uncertain_write(format!("{message}{note}"))
5113}
5114
5115fn write_recovery_marker(cwd: &Path, detail: &str) -> Result<PathBuf> {
5116    use std::sync::atomic::{AtomicU32, Ordering};
5117    static NEXT: AtomicU32 = AtomicU32::new(0);
5118    for _ in 0..1000 {
5119        let serial = NEXT.fetch_add(1, Ordering::Relaxed);
5120        let path = cwd.join(format!(
5121            "{RECOVERY_MARKER_PREFIX}{}-{serial}",
5122            std::process::id()
5123        ));
5124        let mut options = OpenOptions::new();
5125        options.write(true).create_new(true);
5126        #[cfg(unix)]
5127        {
5128            use std::os::unix::fs::OpenOptionsExt;
5129            options.mode(0o600);
5130        }
5131        match options.open(&path) {
5132            Ok(mut file) => {
5133                file.write_all(detail.as_bytes())
5134                    .and_then(|_| file.write_all(b"\n"))
5135                    .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
5136                return Ok(path);
5137            }
5138            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
5139            Err(e) => {
5140                return Err(spar_err!(
5141                    "could not create a recovery marker in {}: {e}",
5142                    cwd.display()
5143                ))
5144            }
5145        }
5146    }
5147    bail!(
5148        "could not choose a free recovery marker name in {}",
5149        cwd.display()
5150    )
5151}
5152
5153/// Build a Git command that cannot launch automatic repository maintenance.
5154///
5155/// A fetch may otherwise prune missing linked worktree registrations. SPAR
5156/// must only remove registrations it has proven it owns.
5157fn git_without_maintenance_argv(args: &[&str]) -> Vec<String> {
5158    let mut argv = vec![
5159        "git".to_string(),
5160        "-c".to_string(),
5161        "maintenance.auto=false".to_string(),
5162        "-c".to_string(),
5163        "gc.auto=0".to_string(),
5164    ];
5165    argv.extend(args.iter().map(|arg| (*arg).to_string()));
5166    argv
5167}
5168
5169fn git_without_automation_argv(args: &[&str]) -> Vec<String> {
5170    let mut argv = git_without_maintenance_argv(&[]);
5171    argv.extend([
5172        "-c".to_string(),
5173        "core.fsmonitor=".to_string(),
5174        "-c".to_string(),
5175        "commit.gpgsign=false".to_string(),
5176        "-c".to_string(),
5177        "core.hooksPath=/dev/null".to_string(),
5178    ]);
5179    argv.extend(args.iter().map(|arg| (*arg).to_string()));
5180    argv
5181}
5182
5183/// Snapshot every untracked file, including ignored files, without changing
5184/// path bytes.
5185///
5186/// Without an exclude option, Git lists both ordinary and ignored untracked
5187/// entries. Metadata fingerprints make overwriting an existing path observable
5188/// without hashing a potentially multi-gigabyte build tree on every call.
5189pub(crate) fn ignored_untracked_state(cwd: &Path) -> Result<IgnoredState> {
5190    let root = std::fs::canonicalize(cwd)
5191        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
5192    let mut files = BTreeMap::new();
5193    let mut ignored = BTreeSet::new();
5194    let mut visited = BTreeSet::new();
5195    collect_untracked_files(
5196        &root,
5197        &root,
5198        Path::new(""),
5199        &mut visited,
5200        &mut files,
5201        &mut ignored,
5202    )?;
5203    Ok(IgnoredState { files, ignored })
5204}
5205
5206fn collect_untracked_files(
5207    root: &Path,
5208    repository: &Path,
5209    prefix: &Path,
5210    visited: &mut BTreeSet<PathBuf>,
5211    files: &mut BTreeMap<PathBuf, UntrackedFile>,
5212    ignored: &mut BTreeSet<PathBuf>,
5213) -> Result<()> {
5214    let canonical = std::fs::canonicalize(repository)
5215        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
5216    if !visited.insert(canonical.clone()) {
5217        bail!("submodule recursion revisited {}", canonical.display());
5218    }
5219    let listed = run_git_bytes(repository, &["ls-files", "--others", "-z"])?;
5220    if !listed.is_empty() && !listed.ends_with(&[0]) {
5221        bail!(
5222            "git returned an unterminated untracked-file list for {}",
5223            repository.display()
5224        );
5225    }
5226
5227    for raw in listed
5228        .split(|byte| *byte == 0)
5229        .filter(|raw| !raw.is_empty())
5230    {
5231        let (relative, nested) = untracked_record(raw, "untracked")?;
5232        let from_root = prefix.join(&relative);
5233        if is_spar_own_path(&from_root) {
5234            continue;
5235        }
5236        let absolute = root.join(&from_root);
5237        let fingerprint = if nested {
5238            nested_repository_fingerprint(&absolute)?
5239        } else {
5240            ignored_file_fingerprint(&absolute)?
5241        };
5242        if files.insert(from_root.clone(), fingerprint).is_some() {
5243            bail!(
5244                "git returned the untracked path more than once: {:?}",
5245                from_root
5246            );
5247        }
5248    }
5249
5250    let ignored_listed = run_git_bytes(
5251        repository,
5252        &[
5253            "ls-files",
5254            "--others",
5255            "--ignored",
5256            "--exclude-standard",
5257            "-z",
5258        ],
5259    )?;
5260    if !ignored_listed.is_empty() && !ignored_listed.ends_with(&[0]) {
5261        bail!(
5262            "git returned an unterminated ignored-file list for {}",
5263            repository.display()
5264        );
5265    }
5266    // Two `ls-files` runs cannot be made to agree about a tree that is being
5267    // built in. A path named only by the second one was created between them,
5268    // which is a fact about the moment, not a corruption: record it and let the
5269    // comparison decide, rather than ending a run that had nothing wrong with
5270    // it.
5271    for raw in ignored_listed
5272        .split(|byte| *byte == 0)
5273        .filter(|raw| !raw.is_empty())
5274    {
5275        let (relative, nested) = untracked_record(raw, "ignored")?;
5276        let from_root = prefix.join(relative);
5277        if is_spar_own_path(&from_root) {
5278            continue;
5279        }
5280        if let std::collections::btree_map::Entry::Vacant(slot) = files.entry(from_root.clone()) {
5281            let absolute = root.join(&from_root);
5282            let fingerprint = if nested {
5283                nested_repository_fingerprint(&absolute)?
5284            } else {
5285                ignored_file_fingerprint(&absolute)?
5286            };
5287            slot.insert(fingerprint);
5288        }
5289        if !ignored.insert(from_root.clone()) {
5290            bail!(
5291                "git returned the ignored path more than once: {:?}",
5292                from_root
5293            );
5294        }
5295    }
5296
5297    for link in gitlinks(repository)? {
5298        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
5299            continue;
5300        };
5301        collect_untracked_files(
5302            root,
5303            &submodule,
5304            &prefix.join(&link.path),
5305            visited,
5306            files,
5307            ignored,
5308        )?;
5309    }
5310    Ok(())
5311}
5312
5313fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
5314    let argv = git_without_automation_argv(args);
5315    proc::run_bytes(
5316        &argv,
5317        &ExecOpts::new()
5318            .cwd(cwd)
5319            .timeout_secs(30)
5320            .stop_descendants(true),
5321    )
5322}
5323
5324fn run_git_text(cwd: &Path, args: &[&str]) -> Result<String> {
5325    let argv = git_without_automation_argv(args);
5326    proc::run(
5327        &argv,
5328        &ExecOpts::new()
5329            .cwd(cwd)
5330            .timeout_secs(30)
5331            .stop_descendants(true),
5332    )
5333}
5334
5335fn filtered_index_content(cwd: &Path, path: &Path, oid: &str) -> Result<[u8; 32]> {
5336    let path = path.to_str().ok_or_else(|| {
5337        spar_err!(
5338            "cannot verify filtered content for a non-UTF-8 path in {}",
5339            cwd.display()
5340        )
5341    })?;
5342    let path_arg = format!("--path={path}");
5343    let bytes = run_git_bytes(cwd, &["cat-file", "--filters", &path_arg, oid])?;
5344    Ok(Sha256::digest(bytes).into())
5345}
5346
5347fn safe_git_path(raw: &[u8], kind: &str) -> Result<PathBuf> {
5348    let relative = path_from_git_bytes(raw)?;
5349    if relative.is_absolute()
5350        || relative.components().any(|component| {
5351            matches!(
5352                component,
5353                std::path::Component::ParentDir
5354                    | std::path::Component::RootDir
5355                    | std::path::Component::Prefix(_)
5356            )
5357        })
5358    {
5359        bail!("git returned an unsafe {kind} path: {:?}", relative);
5360    }
5361    Ok(relative)
5362}
5363
5364/// Split one `ls-files --others` record into its path and whether Git reported
5365/// a nested repository rather than a single file.
5366///
5367/// Git never lists the contents of a repository inside the working tree, so a
5368/// checkout parked there, such as another of SPAR's own worktrees, arrives as
5369/// one record for the directory itself ending in a separator. Git writes that
5370/// separator on every platform. Trimming it keeps the recorded path equal to
5371/// the same path seen any other way.
5372fn untracked_record(raw: &[u8], kind: &str) -> Result<(PathBuf, bool)> {
5373    let nested = raw.last() == Some(&b'/');
5374    let trimmed = if nested { &raw[..raw.len() - 1] } else { raw };
5375    if trimmed.is_empty() {
5376        bail!("git returned an empty {kind} path");
5377    }
5378    Ok((safe_git_path(trimmed, kind)?, nested))
5379}
5380
5381fn index_entries(cwd: &Path) -> Result<Vec<IndexEntry>> {
5382    let listed = run_git_bytes(cwd, &["ls-files", "--stage", "-z"])?;
5383    if !listed.is_empty() && !listed.ends_with(&[0]) {
5384        bail!(
5385            "git returned an unterminated index listing for {}",
5386            cwd.display()
5387        );
5388    }
5389    let mut entries = Vec::new();
5390    for record in listed
5391        .split(|byte| *byte == 0)
5392        .filter(|record| !record.is_empty())
5393    {
5394        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
5395            bail!(
5396                "git returned a malformed index record for {}",
5397                cwd.display()
5398            );
5399        };
5400        let header = &record[..tab];
5401        let fields = header.split(|byte| *byte == b' ').collect::<Vec<_>>();
5402        if fields.len() != 3 {
5403            bail!(
5404                "git returned a malformed index header for {}",
5405                cwd.display()
5406            );
5407        }
5408        if fields[2] != b"0" {
5409            continue;
5410        }
5411        let mode = std::str::from_utf8(fields[0])
5412            .map_err(|_| spar_err!("git returned a non-UTF-8 index mode"))?
5413            .to_string();
5414        let oid = std::str::from_utf8(fields[1])
5415            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
5416            .to_string();
5417        entries.push(IndexEntry {
5418            path: safe_git_path(&record[tab + 1..], "index")?,
5419            mode,
5420            oid,
5421        });
5422    }
5423    Ok(entries)
5424}
5425
5426fn attributes_may_be_modified(cwd: &Path) -> Result<bool> {
5427    let untracked = run_git_bytes(
5428        cwd,
5429        &[
5430            "ls-files",
5431            "--others",
5432            "-z",
5433            "--",
5434            ".gitattributes",
5435            ":(glob)**/.gitattributes",
5436        ],
5437    )?;
5438    if !untracked.is_empty() {
5439        return Ok(true);
5440    }
5441
5442    let index = index_entries(cwd)?
5443        .into_iter()
5444        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5445        .map(|entry| (entry.path, (entry.mode, entry.oid)))
5446        .collect::<BTreeMap<_, _>>();
5447    let head = tree_entries(cwd, "HEAD")?
5448        .into_iter()
5449        .filter(|entry| entry.path.file_name() == Some(OsStr::new(".gitattributes")))
5450        .map(|entry| (entry.path, (entry.mode, entry.oid)))
5451        .collect::<BTreeMap<_, _>>();
5452    if index != head {
5453        return Ok(true);
5454    }
5455
5456    let effective = check_attributes(cwd, index.keys().cloned())?;
5457    let config = CheckoutConfig::read(cwd)?;
5458    for (path, (_mode, oid)) in index {
5459        let Some(worktree) = tracked_worktree_file(&cwd.join(&path), oid.len())? else {
5460            return Ok(true);
5461        };
5462        let attributes = effective
5463            .get(&path)
5464            .ok_or_else(|| spar_err!("git omitted attributes for {}", cwd.join(&path).display()))?;
5465        if allows_expected_crlf(&config, attributes)? {
5466            if worktree.mode == "120000" {
5467                return Ok(true);
5468            }
5469            let (normalized, every_lf_was_crlf) =
5470                normalized_git_blob_oid(&cwd.join(&path), oid.len())?;
5471            if !every_lf_was_crlf || normalized != oid {
5472                return Ok(true);
5473            }
5474        } else if worktree.raw_oid != oid {
5475            return Ok(true);
5476        }
5477    }
5478    Ok(false)
5479}
5480
5481fn gitlinks(cwd: &Path) -> Result<Vec<Gitlink>> {
5482    Ok(index_entries(cwd)?
5483        .into_iter()
5484        .filter(|entry| entry.mode == "160000")
5485        .map(|entry| Gitlink {
5486            path: entry.path,
5487            oid: entry.oid,
5488        })
5489        .collect())
5490}
5491
5492fn tracked_entries(cwd: &Path) -> Result<BTreeMap<PathBuf, TrackedEntry>> {
5493    let mut tracked = BTreeMap::new();
5494    for entry in index_entries(cwd)? {
5495        if entry.mode == "160000" {
5496            continue;
5497        }
5498        let worktree = tracked_worktree_file(&cwd.join(&entry.path), entry.oid.len())?;
5499        tracked.insert(
5500            entry.path,
5501            TrackedEntry {
5502                index_mode: entry.mode,
5503                index_oid: entry.oid,
5504                worktree,
5505            },
5506        );
5507    }
5508    Ok(tracked)
5509}
5510
5511fn tracked_worktree_file(path: &Path, oid_len: usize) -> Result<Option<WorktreeFile>> {
5512    let metadata = match std::fs::symlink_metadata(path) {
5513        Ok(metadata) => metadata,
5514        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
5515        Err(e) => {
5516            return Err(spar_err!(
5517                "could not inspect tracked file {}: {e}",
5518                path.display()
5519            ))
5520        }
5521    };
5522    let mut fingerprint = Sha256::new();
5523    if metadata.file_type().is_symlink() {
5524        let target = std::fs::read_link(path)
5525            .map_err(|e| spar_err!("could not read tracked symlink {}: {e}", path.display()))?;
5526        let bytes = os_str_bytes(target.as_os_str())?;
5527        fingerprint.update(b"symlink\0");
5528        fingerprint.update(&bytes);
5529        let content = Sha256::digest(&bytes).into();
5530        return Ok(Some(WorktreeFile {
5531            mode: "120000".to_string(),
5532            #[cfg(unix)]
5533            permissions: 0,
5534            raw_oid: git_blob_oid(oid_len, &bytes)?,
5535            fingerprint: fingerprint.finalize().into(),
5536            content,
5537        }));
5538    }
5539    if !metadata.is_file() {
5540        bail!("tracked path {} is not a file or symlink", path.display());
5541    }
5542
5543    let mut options = OpenOptions::new();
5544    options.read(true);
5545    #[cfg(unix)]
5546    {
5547        use std::os::unix::fs::OpenOptionsExt;
5548        options.custom_flags(libc::O_NOFOLLOW);
5549    }
5550    let mut file = options
5551        .open(path)
5552        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5553    let before = file
5554        .metadata()
5555        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5556    let mode = tracked_file_mode(&before);
5557    #[cfg(unix)]
5558    let permissions = {
5559        use std::os::unix::fs::MetadataExt;
5560        before.mode() & 0o7777
5561    };
5562    fingerprint.update(b"file\0");
5563    fingerprint.update(mode.as_bytes());
5564    #[cfg(unix)]
5565    fingerprint.update(permissions.to_le_bytes());
5566    fingerprint.update(before.len().to_le_bytes());
5567    let mut content = Sha256::new();
5568    let header = format!("blob {}\0", before.len());
5569    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5570    let mut buf = [0u8; 64 * 1024];
5571    loop {
5572        let read = file
5573            .read(&mut buf)
5574            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5575        if read == 0 {
5576            break;
5577        }
5578        fingerprint.update(&buf[..read]);
5579        content.update(&buf[..read]);
5580        object.update(&buf[..read]);
5581    }
5582    let after = file
5583        .metadata()
5584        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5585    if before.len() != after.len()
5586        || before.modified().ok() != after.modified().ok()
5587        || before.permissions() != after.permissions()
5588    {
5589        bail!(
5590            "tracked file {} changed while it was being inspected",
5591            path.display()
5592        );
5593    }
5594    let current = std::fs::symlink_metadata(path)
5595        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5596    if !same_file(&after, &current) {
5597        bail!(
5598            "tracked file {} was replaced while it was being inspected",
5599            path.display()
5600        );
5601    }
5602    Ok(Some(WorktreeFile {
5603        mode,
5604        #[cfg(unix)]
5605        permissions,
5606        raw_oid: object.finish(),
5607        fingerprint: fingerprint.finalize().into(),
5608        content: content.finalize().into(),
5609    }))
5610}
5611
5612fn attribute_file_fingerprint(path: &Path) -> Result<[u8; 32]> {
5613    let metadata = std::fs::symlink_metadata(path)
5614        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5615    let mut digest = Sha256::new();
5616    if metadata.file_type().is_symlink() {
5617        digest.update(b"symlink\0");
5618        let target = std::fs::read_link(path)
5619            .map_err(|e| spar_err!("could not read attribute symlink {}: {e}", path.display()))?;
5620        digest.update(os_str_bytes(target.as_os_str())?);
5621        return Ok(digest.finalize().into());
5622    }
5623    if !metadata.is_file() {
5624        bail!("attribute path {} is not a file or symlink", path.display());
5625    }
5626    let mut options = OpenOptions::new();
5627    options.read(true);
5628    #[cfg(unix)]
5629    {
5630        use std::os::unix::fs::OpenOptionsExt;
5631        options.custom_flags(libc::O_NOFOLLOW);
5632    }
5633    let mut file = options
5634        .open(path)
5635        .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5636    let before = file
5637        .metadata()
5638        .map_err(|e| spar_err!("could not inspect attribute file {}: {e}", path.display()))?;
5639    digest.update(b"file\0");
5640    let mut buf = [0u8; 64 * 1024];
5641    loop {
5642        let read = file
5643            .read(&mut buf)
5644            .map_err(|e| spar_err!("could not read attribute file {}: {e}", path.display()))?;
5645        if read == 0 {
5646            break;
5647        }
5648        digest.update(&buf[..read]);
5649    }
5650    let after = file
5651        .metadata()
5652        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5653    let current = std::fs::symlink_metadata(path)
5654        .map_err(|e| spar_err!("could not recheck attribute file {}: {e}", path.display()))?;
5655    if before.len() != after.len()
5656        || before.modified().ok() != after.modified().ok()
5657        || !same_file(&after, &current)
5658    {
5659        bail!(
5660            "attribute file {} changed while it was being inspected",
5661            path.display()
5662        );
5663    }
5664    Ok(digest.finalize().into())
5665}
5666
5667enum ObjectHasher {
5668    Sha1(Sha1),
5669    Sha256(Sha256),
5670}
5671
5672impl ObjectHasher {
5673    fn new(oid_len: usize, header: &[u8]) -> Result<Self> {
5674        let mut hasher = match oid_len {
5675            40 => Self::Sha1(<Sha1 as sha1::Digest>::new()),
5676            64 => Self::Sha256(Sha256::new()),
5677            _ => bail!("git returned an object id with an unsupported length: {oid_len}"),
5678        };
5679        hasher.update(header);
5680        Ok(hasher)
5681    }
5682
5683    fn update(&mut self, bytes: &[u8]) {
5684        match self {
5685            Self::Sha1(hasher) => sha1::Digest::update(hasher, bytes),
5686            Self::Sha256(hasher) => hasher.update(bytes),
5687        }
5688    }
5689
5690    fn finish(self) -> String {
5691        let bytes = match self {
5692            Self::Sha1(hasher) => sha1::Digest::finalize(hasher).to_vec(),
5693            Self::Sha256(hasher) => hasher.finalize().to_vec(),
5694        };
5695        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
5696    }
5697}
5698
5699fn git_blob_oid(oid_len: usize, bytes: &[u8]) -> Result<String> {
5700    let header = format!("blob {}\0", bytes.len());
5701    let mut hasher = ObjectHasher::new(oid_len, header.as_bytes())?;
5702    hasher.update(bytes);
5703    Ok(hasher.finish())
5704}
5705
5706fn normalized_git_blob_oid(path: &Path, oid_len: usize) -> Result<(String, bool)> {
5707    let mut first = open_regular_file(path)?;
5708    let first_before = first
5709        .metadata()
5710        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5711    let mut raw_len = 0u64;
5712    let mut crlf_pairs = 0u64;
5713    let mut previous_was_cr = false;
5714    let mut every_lf_was_crlf = true;
5715    let mut buf = [0u8; 64 * 1024];
5716    loop {
5717        let read = first
5718            .read(&mut buf)
5719            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5720        if read == 0 {
5721            break;
5722        }
5723        raw_len = raw_len
5724            .checked_add(read as u64)
5725            .ok_or_else(|| spar_err!("tracked file {} is too large", path.display()))?;
5726        for byte in &buf[..read] {
5727            if *byte == b'\n' {
5728                if previous_was_cr {
5729                    crlf_pairs += 1;
5730                } else {
5731                    every_lf_was_crlf = false;
5732                }
5733            }
5734            previous_was_cr = *byte == b'\r';
5735        }
5736    }
5737    let first_after = first
5738        .metadata()
5739        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5740    let current = std::fs::symlink_metadata(path)
5741        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5742    if raw_len != first_before.len()
5743        || !stable_file_metadata(&first_before, &first_after)
5744        || !stable_file_metadata(&first_after, &current)
5745    {
5746        bail!(
5747            "tracked file {} changed while line endings were inspected",
5748            path.display()
5749        );
5750    }
5751
5752    let normalized_len = raw_len
5753        .checked_sub(crlf_pairs)
5754        .ok_or_else(|| spar_err!("could not normalize tracked file {}", path.display()))?;
5755    let header = format!("blob {normalized_len}\0");
5756    let mut object = ObjectHasher::new(oid_len, header.as_bytes())?;
5757    let mut second = open_regular_file(path)?;
5758    let second_before = second
5759        .metadata()
5760        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5761    if !stable_file_metadata(&first_after, &second_before) {
5762        bail!(
5763            "tracked file {} changed between line-ending checks",
5764            path.display()
5765        );
5766    }
5767    let mut pending_cr = false;
5768    loop {
5769        let read = second
5770            .read(&mut buf)
5771            .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5772        if read == 0 {
5773            break;
5774        }
5775        for byte in &buf[..read] {
5776            if pending_cr {
5777                if *byte == b'\n' {
5778                    object.update(b"\n");
5779                    pending_cr = false;
5780                    continue;
5781                }
5782                object.update(b"\r");
5783                pending_cr = false;
5784            }
5785            if *byte == b'\r' {
5786                pending_cr = true;
5787            } else {
5788                object.update(std::slice::from_ref(byte));
5789            }
5790        }
5791    }
5792    if pending_cr {
5793        object.update(b"\r");
5794    }
5795    let second_after = second
5796        .metadata()
5797        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5798    let current = std::fs::symlink_metadata(path)
5799        .map_err(|e| spar_err!("could not recheck tracked file {}: {e}", path.display()))?;
5800    if !stable_file_metadata(&second_before, &second_after)
5801        || !stable_file_metadata(&second_after, &current)
5802    {
5803        bail!(
5804            "tracked file {} changed while line endings were hashed",
5805            path.display()
5806        );
5807    }
5808    Ok((object.finish(), every_lf_was_crlf))
5809}
5810
5811fn open_regular_file(path: &Path) -> Result<std::fs::File> {
5812    let mut options = OpenOptions::new();
5813    options.read(true);
5814    #[cfg(unix)]
5815    {
5816        use std::os::unix::fs::OpenOptionsExt;
5817        options.custom_flags(libc::O_NOFOLLOW);
5818    }
5819    let file = options
5820        .open(path)
5821        .map_err(|e| spar_err!("could not read tracked file {}: {e}", path.display()))?;
5822    let metadata = file
5823        .metadata()
5824        .map_err(|e| spar_err!("could not inspect tracked file {}: {e}", path.display()))?;
5825    if !metadata.is_file() {
5826        bail!("tracked path {} is not a regular file", path.display());
5827    }
5828    Ok(file)
5829}
5830
5831fn stable_file_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
5832    if !same_file(left, right)
5833        || left.len() != right.len()
5834        || left.modified().ok() != right.modified().ok()
5835        || left.permissions() != right.permissions()
5836    {
5837        return false;
5838    }
5839    #[cfg(unix)]
5840    {
5841        use std::os::unix::fs::MetadataExt;
5842        left.ctime() == right.ctime() && left.ctime_nsec() == right.ctime_nsec()
5843    }
5844    #[cfg(not(unix))]
5845    {
5846        left.created().ok() == right.created().ok()
5847    }
5848}
5849
5850/// How many paths one `check-attr` call is asked about.
5851///
5852/// `check-attr` answers with three NUL fields per attribute per path, so the
5853/// output is several times the input. A whole monorepo in one call is megabytes
5854/// in both directions, and the 30 second timeout below is a timeout on the
5855/// batch. Batching keeps each call small enough to be honest about.
5856const ATTRIBUTE_BATCH: usize = 1000;
5857
5858fn check_attributes(
5859    cwd: &Path,
5860    paths: impl IntoIterator<Item = PathBuf>,
5861) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5862    let paths = paths.into_iter().collect::<BTreeSet<_>>();
5863    if paths.is_empty() {
5864        return Ok(BTreeMap::new());
5865    }
5866    let ordered = paths.into_iter().collect::<Vec<_>>();
5867    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5868    for batch in ordered.chunks(ATTRIBUTE_BATCH) {
5869        values.extend(check_attribute_batch(cwd, batch)?);
5870    }
5871    Ok(values)
5872}
5873
5874fn check_attribute_batch(
5875    cwd: &Path,
5876    batch: &[PathBuf],
5877) -> Result<BTreeMap<PathBuf, BTreeMap<String, String>>> {
5878    const NAMES: [&str; 6] = [
5879        "filter",
5880        "working-tree-encoding",
5881        "ident",
5882        "text",
5883        "eol",
5884        "crlf",
5885    ];
5886    let paths = batch.iter().cloned().collect::<BTreeSet<_>>();
5887    let mut input = String::new();
5888    for path in &paths {
5889        let path = path.to_str().ok_or_else(|| {
5890            spar_err!(
5891                "cannot inspect attributes for a non-UTF-8 path in {}",
5892                cwd.display()
5893            )
5894        })?;
5895        input.push_str(path);
5896        input.push('\0');
5897    }
5898    let argv = git_without_automation_argv(&[
5899        "check-attr",
5900        "-z",
5901        "--cached",
5902        "--stdin",
5903        "filter",
5904        "working-tree-encoding",
5905        "ident",
5906        "text",
5907        "eol",
5908        "crlf",
5909    ]);
5910    let output = proc::run_bytes(
5911        &argv,
5912        &ExecOpts::new()
5913            .cwd(cwd)
5914            .timeout_secs(30)
5915            .stdin(input)
5916            .stop_descendants(true),
5917    )?;
5918    if !output.is_empty() && !output.ends_with(&[0]) {
5919        bail!(
5920            "git returned an unterminated attribute result for {}",
5921            cwd.display()
5922        );
5923    }
5924    let fields = output
5925        .split(|byte| *byte == 0)
5926        .filter(|field| !field.is_empty())
5927        .collect::<Vec<_>>();
5928    if fields.len() != paths.len() * NAMES.len() * 3 {
5929        bail!(
5930            "git returned an unexpected attribute result for {}",
5931            cwd.display()
5932        );
5933    }
5934    let mut values: BTreeMap<PathBuf, BTreeMap<String, String>> = BTreeMap::new();
5935    for record in fields.chunks_exact(3) {
5936        let path = safe_git_path(record[0], "attribute")?;
5937        if !paths.contains(&path) {
5938            bail!(
5939                "git returned attributes for the wrong path in {}",
5940                cwd.display()
5941            );
5942        }
5943        let name = std::str::from_utf8(record[1])
5944            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute name"))?;
5945        let value = std::str::from_utf8(record[2])
5946            .map_err(|_| spar_err!("git returned a non-UTF-8 attribute value"))?;
5947        values
5948            .entry(path)
5949            .or_default()
5950            .insert(name.to_string(), value.to_string());
5951    }
5952    if paths.iter().any(|path| {
5953        values
5954            .get(path)
5955            .is_none_or(|attributes| attributes.len() != NAMES.len())
5956    }) {
5957        bail!(
5958            "git omitted an attribute result for a tracked path in {}",
5959            cwd.display()
5960        );
5961    }
5962    Ok(values)
5963}
5964
5965fn attribute_is_active(value: Option<&String>) -> bool {
5966    !matches!(
5967        value.map(String::as_str),
5968        None | Some("unspecified") | Some("unset")
5969    )
5970}
5971
5972fn path_has_external_transform(values: &BTreeMap<String, String>) -> bool {
5973    attribute_is_active(values.get("filter"))
5974        || attribute_is_active(values.get("working-tree-encoding"))
5975}
5976
5977/// The checkout settings a per-file line-ending decision depends on.
5978///
5979/// These are constant for a worktree, but the checks that read them run once
5980/// per tracked file, and each read was its own `git config` process. A
5981/// thousand-file repository with no `.gitattributes` takes every one of those
5982/// branches, which is half a minute of process spawning per scan, and the sweep
5983/// before a run scans every finished worktree more than once. Read them here,
5984/// once, and hand them down.
5985struct CheckoutConfig {
5986    autocrlf: Option<String>,
5987    eol: Option<String>,
5988    symlinks: Option<bool>,
5989}
5990
5991impl CheckoutConfig {
5992    fn read(cwd: &Path) -> Result<Self> {
5993        Ok(Self {
5994            autocrlf: git_config_value(cwd, "core.autocrlf")?,
5995            eol: git_config_value(cwd, "core.eol")?,
5996            symlinks: git_config_bool(cwd, "core.symlinks")?,
5997        })
5998    }
5999
6000    fn autocrlf_is_true(&self) -> bool {
6001        self.autocrlf.as_ref().is_some_and(|value| {
6002            matches!(
6003                value.to_ascii_lowercase().as_str(),
6004                "true" | "yes" | "on" | "1"
6005            )
6006        })
6007    }
6008
6009    fn eol_is(&self, wanted: &str) -> bool {
6010        self.eol
6011            .as_ref()
6012            .is_some_and(|value| value.eq_ignore_ascii_case(wanted))
6013    }
6014}
6015
6016fn path_has_ambiguous_transform(
6017    config: &CheckoutConfig,
6018    values: &BTreeMap<String, String>,
6019) -> Result<bool> {
6020    if path_has_external_transform(values)
6021        || attribute_is_active(values.get("ident"))
6022        || attribute_is_active(values.get("crlf"))
6023    {
6024        return Ok(true);
6025    }
6026    let text = values.get("text").map(String::as_str);
6027    let eol = values.get("eol").map(String::as_str);
6028    if text == Some("auto") {
6029        return Ok(true);
6030    }
6031    if !matches!(text, Some("set") | Some("unset") | Some("unspecified"))
6032        || !matches!(
6033            eol,
6034            Some("lf") | Some("crlf") | Some("unset") | Some("unspecified")
6035        )
6036    {
6037        return Ok(true);
6038    }
6039    if text == Some("unspecified") && matches!(eol, Some("unspecified") | Some("unset")) {
6040        return Ok(config.autocrlf_is_true());
6041    }
6042    Ok(false)
6043}
6044
6045fn allows_expected_crlf(
6046    config: &CheckoutConfig,
6047    values: &BTreeMap<String, String>,
6048) -> Result<bool> {
6049    if path_has_external_transform(values)
6050        || attribute_is_active(values.get("ident"))
6051        || attribute_is_active(values.get("crlf"))
6052    {
6053        return Ok(false);
6054    }
6055    let text = values.get("text").map(String::as_str);
6056    let eol = values.get("eol").map(String::as_str);
6057    if matches!(text, Some("unset") | Some("auto")) || eol == Some("lf") {
6058        return Ok(false);
6059    }
6060    if eol == Some("crlf") {
6061        return Ok(true);
6062    }
6063    if text != Some("set") {
6064        return Ok(false);
6065    }
6066    if let Some(autocrlf) = config.autocrlf.as_deref() {
6067        match autocrlf.to_ascii_lowercase().as_str() {
6068            "true" | "yes" | "on" | "1" => return Ok(true),
6069            "input" => return Ok(false),
6070            _ => {}
6071        }
6072    }
6073    if config.eol_is("crlf") {
6074        return Ok(true);
6075    }
6076    #[cfg(windows)]
6077    if config.eol.is_none() || config.eol_is("native") {
6078        return Ok(true);
6079    }
6080    Ok(false)
6081}
6082
6083fn git_config_value(cwd: &Path, key: &str) -> Result<Option<String>> {
6084    let argv = git_without_automation_argv(&["config", "--get", key]);
6085    let output = proc::exec(
6086        &argv,
6087        &ExecOpts::new()
6088            .cwd(cwd)
6089            .timeout_secs(30)
6090            .check(false)
6091            .stop_descendants(true),
6092    )?;
6093    match output.code {
6094        0 => Ok(Some(output.stdout.trim().to_string())),
6095        1 => Ok(None),
6096        _ => bail!(
6097            "could not read Git configuration in {}: {}",
6098            cwd.display(),
6099            output.stderr.trim()
6100        ),
6101    }
6102}
6103
6104fn git_config_bool(cwd: &Path, key: &str) -> Result<Option<bool>> {
6105    let argv = git_without_automation_argv(&["config", "--type=bool", "--get", key]);
6106    let output = proc::exec(
6107        &argv,
6108        &ExecOpts::new()
6109            .cwd(cwd)
6110            .timeout_secs(30)
6111            .check(false)
6112            .stop_descendants(true),
6113    )?;
6114    match output.code {
6115        0 if output.stdout.trim() == "true" => Ok(Some(true)),
6116        0 if output.stdout.trim() == "false" => Ok(Some(false)),
6117        0 => bail!(
6118            "git returned an invalid boolean for {key} in {}",
6119            cwd.display()
6120        ),
6121        1 => Ok(None),
6122        _ => bail!(
6123            "could not read Git configuration in {}: {}",
6124            cwd.display(),
6125            output.stderr.trim()
6126        ),
6127    }
6128}
6129
6130#[cfg(unix)]
6131fn tracked_file_mode(metadata: &std::fs::Metadata) -> String {
6132    use std::os::unix::fs::PermissionsExt;
6133    if metadata.permissions().mode() & 0o111 == 0 {
6134        "100644".to_string()
6135    } else {
6136        "100755".to_string()
6137    }
6138}
6139
6140#[cfg(not(unix))]
6141fn tracked_file_mode(_metadata: &std::fs::Metadata) -> String {
6142    "100644".to_string()
6143}
6144
6145fn tree_entries(cwd: &Path, treeish: &str) -> Result<Vec<IndexEntry>> {
6146    let listed = run_git_bytes(cwd, &["ls-tree", "-r", "-z", treeish])?;
6147    if !listed.is_empty() && !listed.ends_with(&[0]) {
6148        bail!(
6149            "git returned an unterminated tree listing for {}",
6150            cwd.display()
6151        );
6152    }
6153    let mut entries = Vec::new();
6154    for record in listed
6155        .split(|byte| *byte == 0)
6156        .filter(|record| !record.is_empty())
6157    {
6158        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
6159            bail!("git returned a malformed tree record for {}", cwd.display());
6160        };
6161        let fields = record[..tab]
6162            .split(|byte| *byte == b' ')
6163            .collect::<Vec<_>>();
6164        if fields.len() != 3 {
6165            bail!("git returned a malformed tree header for {}", cwd.display());
6166        }
6167        let mode = std::str::from_utf8(fields[0])
6168            .map_err(|_| spar_err!("git returned a non-UTF-8 tree mode"))?
6169            .to_string();
6170        let oid = std::str::from_utf8(fields[2])
6171            .map_err(|_| spar_err!("git returned a non-UTF-8 object id"))?
6172            .to_string();
6173        entries.push(IndexEntry {
6174            path: safe_git_path(&record[tab + 1..], "tree")?,
6175            mode,
6176            oid,
6177        });
6178    }
6179    Ok(entries)
6180}
6181
6182fn head_gitlinks(cwd: &Path) -> Result<BTreeMap<PathBuf, String>> {
6183    Ok(tree_entries(cwd, "HEAD")?
6184        .into_iter()
6185        .filter(|entry| entry.mode == "160000")
6186        .map(|entry| (entry.path, entry.oid))
6187        .collect())
6188}
6189
6190fn changed_staged_gitlinks(cwd: &Path) -> Result<Vec<PathBuf>> {
6191    let head = head_gitlinks(cwd)?;
6192    let index: BTreeMap<PathBuf, String> = gitlinks(cwd)?
6193        .into_iter()
6194        .map(|link| (link.path, link.oid))
6195        .collect();
6196    let mut paths: BTreeSet<PathBuf> = head.keys().cloned().collect();
6197    paths.extend(index.keys().cloned());
6198    Ok(paths
6199        .into_iter()
6200        .filter(|path| head.get(path) != index.get(path))
6201        .collect())
6202}
6203
6204fn initialized_submodule(parent: &Path, relative: &Path) -> Result<Option<PathBuf>> {
6205    let path = parent.join(relative);
6206    let metadata = match std::fs::symlink_metadata(&path) {
6207        Ok(metadata) => metadata,
6208        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
6209        Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6210    };
6211    if !metadata.is_dir() {
6212        bail!("the gitlink at {} is not a directory", path.display());
6213    }
6214    let canonical = std::fs::canonicalize(&path)
6215        .map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))?;
6216    if canonical != path {
6217        bail!(
6218            "the gitlink at {} resolves through a symlink",
6219            path.display()
6220        );
6221    }
6222    if !path.join(".git").exists() {
6223        let empty = std::fs::read_dir(&path)
6224            .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?
6225            .next()
6226            .is_none();
6227        if empty {
6228            return Ok(None);
6229        }
6230        bail!(
6231            "the uninitialized gitlink at {} contains local files",
6232            path.display()
6233        );
6234    }
6235    let inside = run_git_text(&path, &["rev-parse", "--is-inside-work-tree"])?;
6236    if inside.trim() != "true" {
6237        bail!("the gitlink at {} is not a worktree", path.display());
6238    }
6239    let top = run_git_text(&path, &["rev-parse", "--show-toplevel"])?;
6240    let top = std::fs::canonicalize(top.trim()).map_err(|e| {
6241        spar_err!(
6242            "could not resolve the gitlink top level at {}: {e}",
6243            path.display()
6244        )
6245    })?;
6246    if top != canonical {
6247        bail!(
6248            "the gitlink at {} belongs to a different worktree",
6249            path.display()
6250        );
6251    }
6252    Ok(Some(canonical))
6253}
6254
6255/// A Git entry the outer repository cannot account for.
6256///
6257/// A checkout Git itself reports as a nested repository is expected. Its
6258/// directory is recorded by identity in the untracked snapshot and keeps the
6259/// worktree from being released, so a reviewer that clones a reproduction into
6260/// ignored build output neither loses that clone nor fails the run over it.
6261/// The scan does not descend into one, because what is under it belongs to
6262/// that repository.
6263///
6264/// A Git entry Git does not report is the one worth stopping for. Running
6265/// `git init` inside a directory that already holds tracked files leaves
6266/// nothing in any listing while making every file written under it invisible
6267/// to the outer worktree, so SPAR can no longer prove what is there.
6268fn unexpected_nested_git_entry(cwd: &Path) -> Result<Option<PathBuf>> {
6269    let root = std::fs::canonicalize(cwd)
6270        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6271    let mut allowed = BTreeSet::from([root.join(".git")]);
6272    let mut checkouts = BTreeSet::new();
6273    let mut repositories = vec![root.clone()];
6274    let mut visited = BTreeSet::new();
6275    while let Some(repository) = repositories.pop() {
6276        let canonical = std::fs::canonicalize(&repository)
6277            .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6278        if !visited.insert(canonical.clone()) {
6279            bail!("submodule recursion revisited {}", canonical.display());
6280        }
6281        for raw in untracked_listing(&canonical, &["ls-files", "--others", "-z"])? {
6282            let (path, nested) = untracked_record(&raw, "untracked")?;
6283            if nested {
6284                checkouts.insert(canonical.join(path));
6285            }
6286        }
6287        for link in gitlinks(&canonical)? {
6288            let Some(submodule) = initialized_submodule(&canonical, &link.path)? else {
6289                continue;
6290            };
6291            allowed.insert(submodule.join(".git"));
6292            repositories.push(submodule);
6293        }
6294    }
6295
6296    // A build deletes its own directories while this walks them. A directory
6297    // that is gone holds no Git entry, so skipping it answers the question
6298    // being asked; erroring instead ended runs whose worktrees were fine.
6299    let scan_root = root.clone();
6300    let mut directories = vec![root];
6301    while let Some(directory) = directories.pop() {
6302        let entries = match std::fs::read_dir(&directory) {
6303            Ok(entries) => entries,
6304            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6305            Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6306        };
6307        for entry in entries {
6308            let entry = match entry {
6309                Ok(entry) => entry,
6310                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6311                Err(e) => return Err(spar_err!("could not inspect {}: {e}", directory.display())),
6312            };
6313            let path = entry.path();
6314            if directory == scan_root && entry.file_name() == OsStr::new(WORKTREE_DIR) {
6315                continue;
6316            }
6317            if entry.file_name() == OsStr::new(".git") {
6318                if !allowed.contains(&path) {
6319                    return Ok(Some(path));
6320                }
6321                continue;
6322            }
6323            let kind = match entry.file_type() {
6324                Ok(kind) => kind,
6325                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
6326                Err(e) => return Err(spar_err!("could not inspect {}: {e}", path.display())),
6327            };
6328            if kind.is_dir() && !checkouts.contains(&path) {
6329                directories.push(path);
6330            }
6331        }
6332    }
6333    Ok(None)
6334}
6335
6336pub(crate) fn git_state(cwd: &Path) -> Result<GitState> {
6337    if let Some(path) = unexpected_nested_git_entry(cwd)? {
6338        bail!(
6339            "the worktree contains a Git entry at {} that Git does not report as a nested \
6340             checkout. Files under it are invisible to the outer worktree, so SPAR cannot \
6341             prove they are unchanged.",
6342            path.display()
6343        );
6344    }
6345    let root = std::fs::canonicalize(cwd)
6346        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6347    let mut repositories = BTreeMap::new();
6348    let mut visited = BTreeSet::new();
6349    collect_git_state(&root, Path::new(""), &mut visited, &mut repositories)?;
6350    Ok(GitState { repositories })
6351}
6352
6353fn collect_git_state(
6354    repository: &Path,
6355    prefix: &Path,
6356    visited: &mut BTreeSet<PathBuf>,
6357    repositories: &mut BTreeMap<PathBuf, RepositoryState>,
6358) -> Result<()> {
6359    let canonical = std::fs::canonicalize(repository)
6360        .map_err(|e| spar_err!("could not resolve {}: {e}", repository.display()))?;
6361    if !visited.insert(canonical.clone()) {
6362        bail!("submodule recursion revisited {}", canonical.display());
6363    }
6364    let head = run_git_text(repository, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6365    let head = head.trim().to_string();
6366    if head.is_empty() {
6367        bail!("git returned an empty head for {}", repository.display());
6368    }
6369    let unsafe_index_flags = unsafe_index_flags(repository)?;
6370    let tracked = tracked_entries(repository)?;
6371    let gitlinks = gitlinks(repository)?;
6372    if repositories
6373        .insert(
6374            prefix.to_path_buf(),
6375            RepositoryState {
6376                head,
6377                unsafe_index_flags,
6378                tracked,
6379                gitlinks: gitlinks
6380                    .iter()
6381                    .map(|link| (link.path.clone(), link.oid.clone()))
6382                    .collect(),
6383            },
6384        )
6385        .is_some()
6386    {
6387        bail!("Git state contains duplicate repository path {:?}", prefix);
6388    }
6389
6390    for link in gitlinks {
6391        let Some(submodule) = initialized_submodule(repository, &link.path)? else {
6392            continue;
6393        };
6394        collect_git_state(&submodule, &prefix.join(&link.path), visited, repositories)?;
6395    }
6396    Ok(())
6397}
6398
6399fn unsafe_index_flags(cwd: &Path) -> Result<Vec<u8>> {
6400    let listed = run_git_bytes(cwd, &["ls-files", "-v", "-z"])?;
6401    if !listed.is_empty() && !listed.ends_with(&[0]) {
6402        bail!(
6403            "git returned an unterminated index-flag listing for {}",
6404            cwd.display()
6405        );
6406    }
6407    let mut unsafe_records = Vec::new();
6408    for record in listed
6409        .split(|byte| *byte == 0)
6410        .filter(|record| !record.is_empty())
6411    {
6412        if record.len() < 3 || record[1] != b' ' {
6413            bail!(
6414                "git returned a malformed index-flag record for {}",
6415                cwd.display()
6416            );
6417        }
6418        if record[0] != b'H' {
6419            unsafe_records.extend_from_slice(record);
6420            unsafe_records.push(0);
6421        }
6422    }
6423    Ok(unsafe_records)
6424}
6425
6426pub(crate) fn refuse_unsafe_index_flags(cwd: &Path) -> Result<()> {
6427    safe_git_state(cwd).map(|_| ())
6428}
6429
6430pub(crate) fn safe_git_state(cwd: &Path) -> Result<GitState> {
6431    let state = git_state(cwd)?;
6432    if let Some((path, _repository)) = state
6433        .repositories
6434        .iter()
6435        .find(|(_, repository)| !repository.unsafe_index_flags.is_empty())
6436    {
6437        let label = if path.as_os_str().is_empty() {
6438            cwd.to_path_buf()
6439        } else {
6440            cwd.join(path)
6441        };
6442        bail!(
6443            "the index at {} has assume-unchanged, skip-worktree, or another nonstandard flag. \
6444             SPAR cannot prove the working files are unchanged, so it was kept.",
6445            label.display()
6446        );
6447    }
6448    Ok(state)
6449}
6450
6451fn repository_has_recoverable_work(cwd: &Path, include_ignored: bool) -> Result<bool> {
6452    repository_has_recoverable_work_with(cwd, include_ignored, Disposable::BuildOutput)
6453}
6454
6455fn repository_has_recoverable_work_with(
6456    cwd: &Path,
6457    include_ignored: bool,
6458    disposable: Disposable,
6459) -> Result<bool> {
6460    if include_ignored && unexpected_nested_git_entry(cwd)?.is_some() {
6461        return Ok(true);
6462    }
6463    let mut visited = BTreeSet::new();
6464    repository_has_recoverable_work_inner(cwd, include_ignored, disposable, &mut visited)
6465}
6466
6467fn has_recoverable_worktree_admin_state(cwd: &Path) -> Result<bool> {
6468    let git_dir = run_git_text(cwd, &["rev-parse", "--git-dir"])?;
6469    let git_dir = PathBuf::from(git_dir.trim());
6470    let git_dir = if git_dir.is_absolute() {
6471        git_dir
6472    } else {
6473        cwd.join(git_dir)
6474    };
6475    let git_dir = std::fs::canonicalize(&git_dir)
6476        .map_err(|e| spar_err!("could not resolve {}: {e}", git_dir.display()))?;
6477    match std::fs::symlink_metadata(git_dir.join("config.worktree")) {
6478        Ok(_) => return Ok(true),
6479        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6480        Err(error) => {
6481            return Err(spar_err!(
6482                "could not inspect per-worktree configuration in {}: {error}",
6483                git_dir.display()
6484            ))
6485        }
6486    }
6487
6488    let orig_head = git_dir.join("ORIG_HEAD");
6489    match std::fs::symlink_metadata(&orig_head) {
6490        Ok(metadata) if metadata.is_file() => {
6491            let oid = std::fs::read_to_string(&orig_head)
6492                .map_err(|e| spar_err!("could not read {}: {e}", orig_head.display()))?;
6493            let Some(commit) = resolve_optional_commit(cwd, oid.trim())? else {
6494                return Ok(true);
6495            };
6496            if !commit_has_shared_ref(cwd, &commit)? {
6497                return Ok(true);
6498            }
6499        }
6500        Ok(_) => return Ok(true),
6501        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6502        Err(error) => {
6503            return Err(spar_err!(
6504                "could not inspect {}: {error}",
6505                orig_head.display()
6506            ))
6507        }
6508    }
6509
6510    let edit_message = git_dir.join("COMMIT_EDITMSG");
6511    match std::fs::symlink_metadata(&edit_message) {
6512        Ok(metadata) if metadata.is_file() => {
6513            let draft = std::fs::read(&edit_message)
6514                .map_err(|e| spar_err!("could not read {}: {e}", edit_message.display()))?;
6515            if draft != head_commit_message(cwd)? {
6516                return Ok(true);
6517            }
6518        }
6519        Ok(_) => return Ok(true),
6520        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
6521        Err(error) => {
6522            return Err(spar_err!(
6523                "could not inspect {}: {error}",
6524                edit_message.display()
6525            ))
6526        }
6527    }
6528
6529    if reflogs_have_unpreserved_commits(cwd, &git_dir.join("logs"))? {
6530        return Ok(true);
6531    }
6532
6533    let local_refs = run_git_bytes(
6534        cwd,
6535        &[
6536            "for-each-ref",
6537            "--format=%(refname)",
6538            "refs/worktree",
6539            "refs/bisect",
6540            "refs/rewritten",
6541        ],
6542    )?;
6543    if !local_refs.is_empty() {
6544        return Ok(true);
6545    }
6546
6547    for entry in std::fs::read_dir(&git_dir)
6548        .map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?
6549    {
6550        let entry = entry.map_err(|e| spar_err!("could not inspect {}: {e}", git_dir.display()))?;
6551        let known = matches!(
6552            entry.file_name().to_str(),
6553            Some(
6554                "HEAD"
6555                    | "ORIG_HEAD"
6556                    | "COMMIT_EDITMSG"
6557                    | "commondir"
6558                    | "gitdir"
6559                    | "index"
6560                    | "logs"
6561                    | "refs"
6562            )
6563        );
6564        if !known {
6565            return Ok(true);
6566        }
6567    }
6568
6569    let head = run_git_text(cwd, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6570    if !commit_has_shared_ref(cwd, head.trim())? {
6571        return Ok(true);
6572    }
6573    Ok(false)
6574}
6575
6576fn head_commit_message(cwd: &Path) -> Result<Vec<u8>> {
6577    let commit = run_git_bytes(cwd, &["cat-file", "commit", "HEAD"])?;
6578    let Some(split) = commit.windows(2).position(|bytes| bytes == b"\n\n") else {
6579        bail!(
6580            "git returned a commit without a message separator in {}",
6581            cwd.display()
6582        );
6583    };
6584    Ok(commit[split + 2..].to_vec())
6585}
6586
6587fn reflogs_have_unpreserved_commits(cwd: &Path, logs: &Path) -> Result<bool> {
6588    let metadata = match std::fs::symlink_metadata(logs) {
6589        Ok(metadata) => metadata,
6590        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6591        Err(error) => return Err(spar_err!("could not inspect {}: {error}", logs.display())),
6592    };
6593    if !metadata.is_dir() {
6594        return Ok(true);
6595    }
6596    let mut files = Vec::new();
6597    let mut directories = vec![logs.to_path_buf()];
6598    while let Some(directory) = directories.pop() {
6599        for entry in std::fs::read_dir(&directory)
6600            .map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?
6601        {
6602            let entry =
6603                entry.map_err(|e| spar_err!("could not inspect {}: {e}", directory.display()))?;
6604            let path = entry.path();
6605            let kind = entry
6606                .file_type()
6607                .map_err(|e| spar_err!("could not inspect {}: {e}", path.display()))?;
6608            if kind.is_dir() {
6609                directories.push(path);
6610            } else if kind.is_file() {
6611                files.push(path);
6612            } else {
6613                return Ok(true);
6614            }
6615        }
6616    }
6617
6618    let mut commits = BTreeSet::new();
6619    for path in files {
6620        if !collect_reflog_commits(cwd, &path, &mut commits)? {
6621            return Ok(true);
6622        }
6623    }
6624    for commit in commits {
6625        if !commit_has_shared_ref(cwd, &commit)? {
6626            return Ok(true);
6627        }
6628    }
6629    Ok(false)
6630}
6631
6632/// Every commit named by one ref's common reflog must survive deletion of that
6633/// ref. Ancestors of a durable current tip survive with the tip; divergent
6634/// entries need another shared ref of their own.
6635fn ref_reflog_is_preserved(cwd: &Path, refname: &str, durable_tip: &str) -> Result<bool> {
6636    let common = common_git_dir(cwd)?;
6637    let reflog = common.join("logs").join(refname);
6638    let metadata = match std::fs::symlink_metadata(&reflog) {
6639        Ok(metadata) => metadata,
6640        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true),
6641        Err(error) => return Err(spar_err!("could not inspect {}: {error}", reflog.display())),
6642    };
6643    if !metadata.is_file() {
6644        return Ok(false);
6645    }
6646    let mut commits = BTreeSet::new();
6647    if !collect_reflog_commits(cwd, &reflog, &mut commits)? {
6648        return Ok(false);
6649    }
6650    for commit in commits {
6651        if is_ancestor(cwd, &commit, durable_tip)?
6652            || commit_has_shared_ref_except(cwd, &commit, Some(refname))?
6653        {
6654            continue;
6655        }
6656        return Ok(false);
6657    }
6658    Ok(true)
6659}
6660
6661fn collect_reflog_commits(cwd: &Path, path: &Path, commits: &mut BTreeSet<String>) -> Result<bool> {
6662    let file = std::fs::File::open(path)
6663        .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6664    for line in std::io::BufReader::new(file).lines() {
6665        let line = line.map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
6666        let mut fields = line.splitn(3, ' ');
6667        let Some(old) = fields.next() else {
6668            return Ok(false);
6669        };
6670        let Some(new) = fields.next() else {
6671            return Ok(false);
6672        };
6673        if fields.next().is_none() {
6674            return Ok(false);
6675        }
6676        for oid in [old, new] {
6677            if oid.bytes().all(|byte| byte == b'0') {
6678                continue;
6679            }
6680            let Some(commit) = resolve_optional_commit(cwd, oid)? else {
6681                return Ok(false);
6682            };
6683            commits.insert(commit);
6684        }
6685    }
6686    Ok(true)
6687}
6688
6689fn common_git_dir(cwd: &Path) -> Result<PathBuf> {
6690    let raw = run_git_text(cwd, &["rev-parse", "--git-common-dir"])?;
6691    let path = PathBuf::from(raw.trim());
6692    let path = if path.is_absolute() {
6693        path
6694    } else {
6695        cwd.join(path)
6696    };
6697    std::fs::canonicalize(&path).map_err(|e| spar_err!("could not resolve {}: {e}", path.display()))
6698}
6699
6700fn is_ancestor(cwd: &Path, older: &str, newer: &str) -> Result<bool> {
6701    let argv = git_without_automation_argv(&["merge-base", "--is-ancestor", older, newer]);
6702    let output = proc::exec(
6703        &argv,
6704        &ExecOpts::new()
6705            .cwd(cwd)
6706            .timeout_secs(30)
6707            .check(false)
6708            .stop_descendants(true),
6709    )?;
6710    match output.code {
6711        0 => Ok(true),
6712        1 => Ok(false),
6713        _ => bail!("{}", proc::failure_message(&argv, &output)),
6714    }
6715}
6716
6717fn resolve_optional_commit(cwd: &Path, oid: &str) -> Result<Option<String>> {
6718    let commit = format!("{oid}^{{commit}}");
6719    let argv = git_without_automation_argv(&["rev-parse", "--quiet", "--verify", &commit]);
6720    let output = proc::exec(
6721        &argv,
6722        &ExecOpts::new()
6723            .cwd(cwd)
6724            .timeout_secs(30)
6725            .check(false)
6726            .stop_descendants(true),
6727    )?;
6728    if output.code != 0 {
6729        return Ok(None);
6730    }
6731    let oid = output.stdout.trim();
6732    if oid.is_empty() {
6733        return Ok(None);
6734    }
6735    Ok(Some(oid.to_string()))
6736}
6737
6738fn commit_has_shared_ref(cwd: &Path, oid: &str) -> Result<bool> {
6739    commit_has_shared_ref_except(cwd, oid, None)
6740}
6741
6742fn commit_has_shared_ref_except(cwd: &Path, oid: &str, exclude: Option<&str>) -> Result<bool> {
6743    let contains = format!("--contains={oid}");
6744    let shared = run_git_bytes(cwd, &["for-each-ref", "--format=%(refname)", &contains])?;
6745    Ok(shared.split(|byte| *byte == b'\n').any(|record| {
6746        !record.is_empty()
6747            && !record.starts_with(b"refs/worktree/")
6748            && !record.starts_with(b"refs/bisect/")
6749            && !record.starts_with(b"refs/rewritten/")
6750            && exclude.is_none_or(|excluded| record != excluded.as_bytes())
6751    }))
6752}
6753
6754/// Whether removing the worktree would take away an untracked file somebody
6755/// might want back.
6756///
6757/// Ordinary untracked files always count. So does an ignored file outside the
6758/// known build and cache directories, because an ignored path is only a path
6759/// Git was told not to track, which is where a local `.env` lives as readily as
6760/// compiler output.
6761///
6762/// Recognized build and cache output does not. A managed commit already leaves
6763/// it out rather than treating it as work, and the command that wrote it writes
6764/// it again. Counting it kept every worktree whose tests or build had run,
6765/// which is nearly all of them, so a merged pull request still left its
6766/// checkout behind. A repository nested in that output is somebody else's
6767/// history and counts whatever it sits under.
6768fn has_untracked_work_worth_keeping(cwd: &Path, disposable: Disposable) -> Result<bool> {
6769    let ordinary = untracked_listing(cwd, &["ls-files", "--others", "--exclude-standard", "-z"])?;
6770    for raw in &ordinary {
6771        let (path, _) = untracked_record(raw, "untracked")?;
6772        if disposable == Disposable::ReviewOutput && is_spar_marker(&path) {
6773            continue;
6774        }
6775        return Ok(true);
6776    }
6777    let listed = untracked_listing(
6778        cwd,
6779        &[
6780            "ls-files",
6781            "--others",
6782            "--ignored",
6783            "--exclude-standard",
6784            "-z",
6785        ],
6786    )?;
6787    for raw in listed {
6788        let (path, nested) = untracked_record(&raw, "ignored")?;
6789        // A repository cloned into the output keeps the checkout whatever the
6790        // mode: its objects exist nowhere else, which is the one thing a
6791        // rebuild from origin cannot replace.
6792        if nested {
6793            return Ok(true);
6794        }
6795        if disposable == Disposable::BuildOutput && !is_generated_artifact(&path) {
6796            return Ok(true);
6797        }
6798    }
6799    Ok(false)
6800}
6801
6802fn untracked_listing(cwd: &Path, args: &[&str]) -> Result<Vec<Vec<u8>>> {
6803    let listed = run_git_bytes(cwd, args)?;
6804    if !listed.is_empty() && !listed.ends_with(&[0]) {
6805        bail!(
6806            "git returned an unterminated untracked-file list for {}",
6807            cwd.display()
6808        );
6809    }
6810    Ok(listed
6811        .split(|byte| *byte == 0)
6812        .filter(|raw| !raw.is_empty())
6813        .map(|raw| raw.to_vec())
6814        .collect())
6815}
6816
6817fn repository_has_recoverable_work_inner(
6818    cwd: &Path,
6819    include_ignored: bool,
6820    disposable: Disposable,
6821    visited: &mut BTreeSet<PathBuf>,
6822) -> Result<bool> {
6823    let canonical = std::fs::canonicalize(cwd)
6824        .map_err(|e| spar_err!("could not resolve {}: {e}", cwd.display()))?;
6825    if !visited.insert(canonical.clone()) {
6826        bail!("submodule recursion revisited {}", canonical.display());
6827    }
6828    if include_ignored && has_untracked_work_worth_keeping(cwd, disposable)? {
6829        return Ok(true);
6830    }
6831    if !unsafe_index_flags(cwd)?.is_empty() {
6832        return Ok(true);
6833    }
6834    if attributes_may_be_modified(cwd)? {
6835        return Ok(true);
6836    }
6837    if include_ignored && has_recoverable_worktree_admin_state(cwd)? {
6838        return Ok(true);
6839    }
6840    if include_ignored {
6841        let index = index_entries(cwd)?
6842            .into_iter()
6843            .map(|entry| (entry.path, (entry.mode, entry.oid)))
6844            .collect::<BTreeMap<_, _>>();
6845        let head = tree_entries(cwd, "HEAD")?
6846            .into_iter()
6847            .map(|entry| (entry.path, (entry.mode, entry.oid)))
6848            .collect::<BTreeMap<_, _>>();
6849        if index != head || !run_git_bytes(cwd, &["ls-files", "--unmerged", "-z"])?.is_empty() {
6850            return Ok(true);
6851        }
6852        let tracked = tracked_entries(cwd)?;
6853        let effective = check_attributes(cwd, tracked.keys().cloned())?;
6854        let config = CheckoutConfig::read(cwd)?;
6855        for (path, entry) in tracked {
6856            let Some(worktree) = entry.worktree else {
6857                return Ok(true);
6858            };
6859            let attributes = effective.get(&path).ok_or_else(|| {
6860                spar_err!("git omitted attributes for {}", cwd.join(&path).display())
6861            })?;
6862            if path_has_ambiguous_transform(&config, attributes)? {
6863                return Ok(true);
6864            }
6865            let symlink_file = entry.index_mode == "120000"
6866                && worktree.mode == "100644"
6867                && worktree.raw_oid == entry.index_oid
6868                && config.symlinks == Some(false);
6869            if worktree.mode != entry.index_mode && !symlink_file {
6870                return Ok(true);
6871            }
6872            if entry.index_mode == "120000" {
6873                if worktree.raw_oid != entry.index_oid {
6874                    return Ok(true);
6875                }
6876                continue;
6877            }
6878            #[cfg(unix)]
6879            {
6880                let expected = if entry.index_mode == "100755" {
6881                    0o755
6882                } else {
6883                    0o644
6884                };
6885                if worktree.permissions != expected {
6886                    return Ok(true);
6887                }
6888            }
6889            if allows_expected_crlf(&config, attributes)? {
6890                let (normalized, every_lf_was_crlf) =
6891                    normalized_git_blob_oid(&cwd.join(&path), entry.index_oid.len())?;
6892                if !every_lf_was_crlf || normalized != entry.index_oid {
6893                    return Ok(true);
6894                }
6895            } else if worktree.raw_oid != entry.index_oid {
6896                return Ok(true);
6897            }
6898        }
6899    } else {
6900        let args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
6901        if !run_git_bytes(cwd, &args)?.is_empty() {
6902            return Ok(true);
6903        }
6904    }
6905    for link in gitlinks(cwd)? {
6906        let Some(submodule) = initialized_submodule(cwd, &link.path)? else {
6907            continue;
6908        };
6909        // Git stores a linked worktree's initialized submodule objects under
6910        // that worktree's administrative directory. Ordinary removal cannot
6911        // prove a local submodule commit exists anywhere else, even when both
6912        // working trees look clean.
6913        if include_ignored {
6914            return Ok(true);
6915        }
6916        let head = run_git_text(&submodule, &["rev-parse", "--verify", "HEAD^{commit}"])?;
6917        if head.trim() != link.oid {
6918            return Ok(true);
6919        }
6920        if repository_has_recoverable_work_inner(&submodule, include_ignored, disposable, visited)?
6921        {
6922            return Ok(true);
6923        }
6924    }
6925    Ok(false)
6926}
6927
6928pub(crate) fn has_uncommitted_work(cwd: &Path) -> Result<bool> {
6929    repository_has_recoverable_work(cwd, false)
6930}
6931
6932fn has_tracked_or_staged_work(cwd: &Path) -> Result<bool> {
6933    let args = ["status", "--porcelain=v1", "-z", "--untracked-files=no"];
6934    Ok(!run_git_bytes(cwd, &args)?.is_empty())
6935}
6936
6937#[cfg(unix)]
6938fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6939    use std::os::unix::ffi::OsStringExt;
6940    Ok(PathBuf::from(std::ffi::OsString::from_vec(raw.to_vec())))
6941}
6942
6943#[cfg(not(unix))]
6944fn path_from_git_bytes(raw: &[u8]) -> Result<PathBuf> {
6945    String::from_utf8(raw.to_vec())
6946        .map(PathBuf::from)
6947        .map_err(|_| spar_err!("git returned a non-UTF-8 ignored path"))
6948}
6949
6950/// Fingerprint the directory of a nested repository without reading inside it.
6951///
6952/// The files under it are that repository's, not this one's. They are recorded
6953/// against its own baseline whenever SPAR works there, and a run of its own may
6954/// legitimately add or remove entries while this call is in flight, so the
6955/// volatile directory fields stay out of the fingerprint. Identity and type
6956/// remain, which is what makes deleting the checkout, or replacing it with a
6957/// file, observable from the outer worktree.
6958/// What a path Git listed looks like once it is no longer there.
6959///
6960/// A reviewer building the project deletes and rewrites its output constantly,
6961/// so a path can go between the listing that named it and the stat that
6962/// measures it. Treating that as an unreadable worktree ended runs that had
6963/// nothing wrong with them. The record stays in the snapshot rather than being
6964/// dropped, so a path that vanishes cannot pass for one that was never there.
6965fn vanished_untracked_file() -> UntrackedFile {
6966    UntrackedFile {
6967        kind: 0,
6968        len: 0,
6969        modified: None,
6970        created: None,
6971        readonly: false,
6972        symlink_target: None,
6973        #[cfg(unix)]
6974        device: 0,
6975        #[cfg(unix)]
6976        inode: 0,
6977        #[cfg(unix)]
6978        mode: 0,
6979        #[cfg(unix)]
6980        change_seconds: 0,
6981        #[cfg(unix)]
6982        change_nanoseconds: 0,
6983    }
6984}
6985
6986fn nested_repository_fingerprint(path: &Path) -> Result<UntrackedFile> {
6987    let metadata = match std::fs::symlink_metadata(path) {
6988        Ok(metadata) => metadata,
6989        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
6990        Err(e) => {
6991            return Err(spar_err!(
6992                "could not inspect the nested repository at {}: {e}",
6993                path.display()
6994            ))
6995        }
6996    };
6997    if !metadata.is_dir() {
6998        bail!(
6999            "git reported {} as a nested repository, but it is not a directory",
7000            path.display()
7001        );
7002    }
7003    if std::fs::symlink_metadata(path.join(".git")).is_err() {
7004        bail!(
7005            "git reported {} as a nested repository, but it has no Git entry",
7006            path.display()
7007        );
7008    }
7009    #[cfg(unix)]
7010    {
7011        use std::os::unix::fs::MetadataExt;
7012        Ok(UntrackedFile {
7013            kind: 3,
7014            len: 0,
7015            modified: None,
7016            created: metadata.created().ok(),
7017            readonly: metadata.permissions().readonly(),
7018            symlink_target: None,
7019            device: metadata.dev(),
7020            inode: metadata.ino(),
7021            mode: metadata.mode(),
7022            change_seconds: 0,
7023            change_nanoseconds: 0,
7024        })
7025    }
7026    #[cfg(not(unix))]
7027    {
7028        Ok(UntrackedFile {
7029            kind: 3,
7030            len: 0,
7031            modified: None,
7032            created: metadata.created().ok(),
7033            readonly: metadata.permissions().readonly(),
7034            symlink_target: None,
7035        })
7036    }
7037}
7038
7039fn ignored_file_fingerprint(path: &Path) -> Result<UntrackedFile> {
7040    let metadata = match std::fs::symlink_metadata(path) {
7041        Ok(metadata) => metadata,
7042        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vanished_untracked_file()),
7043        Err(e) => {
7044            return Err(spar_err!(
7045                "could not inspect untracked file {}: {e}",
7046                path.display()
7047            ))
7048        }
7049    };
7050    let kind = if metadata.file_type().is_symlink() {
7051        2
7052    } else if metadata.is_file() {
7053        1
7054    } else {
7055        bail!(
7056            "untracked path {} is not a regular file or symlink",
7057            path.display()
7058        );
7059    };
7060    let symlink_target = if kind == 2 {
7061        match std::fs::read_link(path) {
7062            Ok(target) => Some(os_str_bytes(target.as_os_str())?),
7063            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7064                return Ok(vanished_untracked_file())
7065            }
7066            Err(e) => {
7067                return Err(spar_err!(
7068                    "could not read untracked symlink {}: {e}",
7069                    path.display()
7070                ))
7071            }
7072        }
7073    } else {
7074        None
7075    };
7076    #[cfg(unix)]
7077    {
7078        use std::os::unix::fs::MetadataExt;
7079        Ok(UntrackedFile {
7080            kind,
7081            len: metadata.len(),
7082            modified: metadata.modified().ok(),
7083            created: metadata.created().ok(),
7084            readonly: metadata.permissions().readonly(),
7085            symlink_target,
7086            device: metadata.dev(),
7087            inode: metadata.ino(),
7088            mode: metadata.mode(),
7089            change_seconds: metadata.ctime(),
7090            change_nanoseconds: metadata.ctime_nsec(),
7091        })
7092    }
7093    #[cfg(not(unix))]
7094    {
7095        Ok(UntrackedFile {
7096            kind,
7097            len: metadata.len(),
7098            modified: metadata.modified().ok(),
7099            created: metadata.created().ok(),
7100            readonly: metadata.permissions().readonly(),
7101            symlink_target,
7102        })
7103    }
7104}
7105
7106#[cfg(unix)]
7107fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7108    use std::os::unix::ffi::OsStrExt;
7109    Ok(value.as_bytes().to_vec())
7110}
7111
7112#[cfg(not(unix))]
7113fn os_str_bytes(value: &OsStr) -> Result<Vec<u8>> {
7114    value
7115        .to_str()
7116        .map(|value| value.as_bytes().to_vec())
7117        .ok_or_else(|| spar_err!("a filesystem path is not UTF-8"))
7118}
7119
7120#[cfg(unix)]
7121fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7122    use std::os::unix::fs::MetadataExt;
7123    right.is_file() && left.dev() == right.dev() && left.ino() == right.ino()
7124}
7125
7126#[cfg(not(unix))]
7127fn same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
7128    right.is_file() && left.len() == right.len() && left.permissions() == right.permissions()
7129}
7130
7131#[derive(Debug, Clone, serde::Serialize, Deserialize)]
7132pub struct BranchRecord {
7133    pub kind: String,
7134    pub number: i64,
7135}
7136
7137/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
7138/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
7139pub fn review_ref(number: i64) -> String {
7140    format!("refs/spar/pr-{number}")
7141}
7142
7143pub fn is_finished(state: &str) -> bool {
7144    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
7145}
7146
7147/// Write text through a temporary file and rename, so a kill cannot leave a
7148/// truncated file behind.
7149///
7150/// The follow-up queue is the one file spar rewrites in place rather than
7151/// appends to, and a truncated queue is lost work: what it held was never
7152/// written anywhere else.
7153pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
7154    if let Some(parent) = path.parent() {
7155        std::fs::create_dir_all(parent)
7156            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
7157    }
7158    // The extension defaults to `json` so `clear_state`, which removes a
7159    // leftover `pr-N.json.tmp` by name, keeps finding the one this wrote.
7160    let tmp = path.with_extension(format!(
7161        "{}.tmp",
7162        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
7163    ));
7164    std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
7165    std::fs::rename(&tmp, path)
7166        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
7167    Ok(())
7168}
7169
7170/// Whether a follow-up file already has an entry under this exact title.
7171///
7172/// Whole heading lines, case insensitively. A substring check answered yes
7173/// for "Retry loop" against "## Retry loop never terminates", which is a
7174/// different defect, and the new one was dropped with a line saying it was
7175/// already noted.
7176pub fn has_heading(text: &str, title: &str) -> bool {
7177    let wanted = title.trim().to_lowercase();
7178    if wanted.is_empty() {
7179        return false;
7180    }
7181    text.lines()
7182        .filter_map(|line| line.trim().strip_prefix("## "))
7183        .any(|heading| heading.trim().to_lowercase() == wanted)
7184}
7185
7186/// Write JSON through a temporary file and rename, so a kill cannot leave a
7187/// truncated state file behind.
7188pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
7189    write_text_atomic(path, &serde_json::to_string_pretty(value)?)
7190}
7191
7192/// One row of `gh pr list --json number,url,title,baseRefName,closingIssuesReferences`.
7193#[derive(Deserialize)]
7194#[serde(rename_all = "camelCase")]
7195pub struct LinkedPrRow {
7196    number: i64,
7197    #[serde(default)]
7198    url: String,
7199    #[serde(default)]
7200    title: String,
7201    #[serde(default)]
7202    closing_issues_references: Vec<IssueRef>,
7203}
7204
7205/// The listed pull requests, or `None` when the payload could not be read.
7206///
7207/// Separated from the gh call so the real payload shape can be tested, and kept
7208/// distinct from an empty list, because silently failing to parse would look
7209/// exactly like "no pull request exists", which is the answer that makes spar
7210/// implement over the top of somebody's work.
7211pub fn linked_pr_rows(json: &str) -> Option<Vec<LinkedPrRow>> {
7212    serde_json::from_str::<Vec<LinkedPrRow>>(json.trim()).ok()
7213}
7214
7215/// Among the listed pull requests, the first that would close `issue`.
7216pub fn pick_linked_pr(rows: &[LinkedPrRow], issue: i64) -> Option<PrRef> {
7217    rows.iter()
7218        .find(|row| {
7219            row.closing_issues_references
7220                .iter()
7221                .any(|linked| linked.number == issue)
7222        })
7223        .map(|row| PrRef {
7224            number: row.number,
7225            url: row.url.clone(),
7226            title: row.title.clone(),
7227        })
7228}
7229
7230/// Both halves together, for the tests that hold the real payload.
7231pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
7232    pick_linked_pr(&linked_pr_rows(json)?, issue)
7233}
7234
7235/// Flatten whatever `gh api --paginate` printed into a list of comments.
7236///
7237/// Current gh merges array pages into one array. Older builds concatenated one
7238/// document per page. A streaming parser reads either, and unlike splitting the
7239/// text on a bracket pair it cannot be fooled by a comment body that happens to
7240/// contain one, which would otherwise make a resume silently start over.
7241fn try_parse_comment_pages(text: &str) -> Result<Vec<Value>> {
7242    if text.trim().is_empty() {
7243        return Err(spar_err!("GitHub returned no comment data"));
7244    }
7245    let mut out = Vec::new();
7246    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7247        match value.map_err(|e| spar_err!("unexpected comment pages: {e}"))? {
7248            Value::Array(items) => out.extend(items),
7249            _ => return Err(spar_err!("unexpected non-array comment page")),
7250        }
7251    }
7252    Ok(out)
7253}
7254
7255pub fn parse_comment_pages(text: &str) -> Vec<Value> {
7256    let mut out = Vec::new();
7257    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
7258        match value {
7259            Ok(Value::Array(items)) => out.extend(items),
7260            Ok(other) => out.push(other),
7261            Err(_) => break,
7262        }
7263    }
7264    out
7265}
7266
7267/// Whether a comment is a state block spar itself wrote.
7268///
7269/// The body has to open with the marker, so a person quoting it in a sentence
7270/// is not one; it has to parse, so a half-quoted block is not one; and it has
7271/// to be the viewer's own comment, the way `comments.rs` already tells spar's
7272/// writes apart. Anything less and `write_pr_state` patches somebody's comment
7273/// with JSON, and `spar clean --pr-state` later deletes it.
7274pub fn is_state_comment(body: &str, author: &str, viewer: &str) -> bool {
7275    body.trim_start().starts_with(STATE_MARKER)
7276        && !viewer.trim().is_empty()
7277        && author.trim().eq_ignore_ascii_case(viewer.trim())
7278        && parse_state_comment(body).is_some()
7279}
7280
7281/// Extract the payload from a state comment. The marker is followed by JSON and
7282/// terminated with `-->`.
7283pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
7284    let marker = body.find(STATE_MARKER)?;
7285    let start = body[marker..].find('{')? + marker;
7286    let end = body.rfind('}')?;
7287    if end <= start {
7288        return None;
7289    }
7290    match serde_json::from_str(&body[start..=end]) {
7291        Ok(state) => Some(state),
7292        Err(_) => {
7293            logdim!("found a spar state comment but could not parse it");
7294            None
7295        }
7296    }
7297}
7298
7299fn choose_state_for_head(
7300    candidates: Vec<PersistedState>,
7301    actual_head: &str,
7302) -> Option<PersistedState> {
7303    let matching: Vec<PersistedState> = candidates
7304        .iter()
7305        .filter(|state| state.pr_head == actual_head)
7306        .cloned()
7307        .collect();
7308    if !matching.is_empty() {
7309        return newest_state(matching);
7310    }
7311    newest_state(candidates)
7312}
7313
7314fn newest_state(candidates: Vec<PersistedState>) -> Option<PersistedState> {
7315    candidates.into_iter().reduce(|best, candidate| {
7316        if (candidate.checkpoint, candidate.round) > (best.checkpoint, best.round) {
7317            candidate
7318        } else {
7319            // The local candidate is supplied first. Keeping the first exact
7320            // tie recovers correctly from a local write followed by a failed
7321            // pull request state update, including legacy states with no
7322            // checkpoint field.
7323            best
7324        }
7325    })
7326}
7327
7328/// Where this binary lives, so `git filter-branch` can call back into it.
7329///
7330/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
7331/// tests, whose `current_exe` is the test harness rather than spar, and for
7332/// anyone who ships spar behind a wrapper script.
7333pub fn self_binary() -> Result<PathBuf> {
7334    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
7335        let path = PathBuf::from(path);
7336        if proc::is_executable(&path) {
7337            return Ok(path);
7338        }
7339        bail!(
7340            "SPAR_SELF_BIN is set to {}, which is not executable",
7341            path.display()
7342        );
7343    }
7344    std::env::current_exe()
7345        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
7346}
7347
7348fn bool_env(value: bool) -> &'static str {
7349    if value {
7350        "1"
7351    } else {
7352        "0"
7353    }
7354}
7355
7356/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
7357/// shell command, and an install path with a space in it is not exotic.
7358pub fn sh_quote(text: &str) -> String {
7359    format!("'{}'", text.replace('\'', r"'\''"))
7360}
7361
7362/// Style rules for the `scrub-filter` subcommand, which runs in a child process
7363/// spawned by git and so cannot see the parent's config.
7364pub fn style_from_env() -> Style {
7365    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
7366    Style {
7367        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
7368        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
7369        ..Style::permissive()
7370    }
7371}
7372
7373#[cfg(test)]
7374mod tests {
7375    use super::*;
7376    use crate::config::StateStore;
7377    use crate::model::{Dispute, Finding, Ledger, PersistedState, Severity, Status};
7378    use std::process::Command;
7379
7380    fn repo_for_titles() -> Repo {
7381        Repo {
7382            root: PathBuf::from("/nonexistent"),
7383            style: Style::default(),
7384            branch_prefix: String::new(),
7385            state_store: StateStore::Local,
7386            followups: crate::config::Followups::Issues,
7387            drafts: Drafts::Never,
7388            viewer: OnceLock::new(),
7389            checkpoints: Mutex::new(BTreeMap::new()),
7390            writes: WriteStats::default(),
7391        }
7392    }
7393
7394    #[test]
7395    fn write_results_accumulate_for_the_run() {
7396        let repo = repo_for_titles();
7397
7398        let _: std::result::Result<(), ()> = repo.record_write(Ok(()));
7399        let _: std::result::Result<(), ()> = repo.record_write(Err(()));
7400
7401        assert_eq!(
7402            WriteSummary {
7403                attempted: 2,
7404                failed: 1,
7405            },
7406            repo.write_summary()
7407        );
7408    }
7409
7410    #[test]
7411    fn only_failed_write_preflights_join_the_summary() {
7412        let repo = repo_for_titles();
7413
7414        let _: std::result::Result<(), ()> = repo.record_failed_write(Ok(()));
7415        let _: std::result::Result<(), ()> = repo.record_failed_write(Err(()));
7416
7417        assert_eq!(
7418            WriteSummary {
7419                attempted: 1,
7420                failed: 1,
7421            },
7422            repo.write_summary()
7423        );
7424    }
7425
7426    #[test]
7427    fn a_nonempty_write_title_that_cleans_to_empty_is_one_failed_preflight() {
7428        let repo = repo_for_titles();
7429
7430        assert!(repo.clean_nonempty_title_for_write("\u{1F916}").is_err());
7431        assert_eq!(
7432            WriteSummary {
7433                attempted: 1,
7434                failed: 1,
7435            },
7436            repo.write_summary()
7437        );
7438    }
7439
7440    #[test]
7441    fn a_local_followup_title_failure_is_not_a_remote_write_failure() {
7442        let mut repo = repo_for_titles();
7443        repo.followups = Followups::Local;
7444
7445        assert_eq!("", repo.clean_followup_title("\u{1F916}").unwrap());
7446        assert_eq!(WriteSummary::default(), repo.write_summary());
7447    }
7448
7449    #[test]
7450    fn a_failed_remote_state_read_stops_before_state_mutation() {
7451        let root = std::env::temp_dir().join(format!(
7452            "spar-state-preflight-{}-{}",
7453            std::process::id(),
7454            std::time::SystemTime::now()
7455                .duration_since(std::time::UNIX_EPOCH)
7456                .unwrap()
7457                .as_nanos()
7458        ));
7459        std::fs::create_dir_all(&root).unwrap();
7460        let _fixture = ReviewFixture { root: root.clone() };
7461        let mut repo = repo_for_titles();
7462        repo.root = root;
7463        repo.state_store = StateStore::Both;
7464        let state = PersistedState {
7465            version: 1,
7466            checkpoint: 4,
7467            round: 2,
7468            next_actor: "a".into(),
7469            status: Status::Pending,
7470            pr_head: "abc123".into(),
7471            ledger: Ledger::new(),
7472            filed: Vec::new(),
7473            open_findings: Vec::new(),
7474            disputes: Vec::new(),
7475            noted: Vec::new(),
7476        };
7477
7478        let error = repo
7479            .write_state_after_remote_read(
7480                7,
7481                &state,
7482                Err(crate::error::SparError::new("state comments unavailable")),
7483            )
7484            .unwrap_err();
7485
7486        assert!(error.to_string().contains("state comments unavailable"));
7487        assert!(!repo.state_path(7).exists());
7488        assert_eq!(0, repo.remembered_checkpoint(7));
7489        assert_eq!(
7490            WriteSummary {
7491                attempted: 1,
7492                failed: 1,
7493            },
7494            repo.write_summary()
7495        );
7496    }
7497
7498    #[test]
7499    fn only_known_build_and_cache_directories_are_generated_artifacts() {
7500        assert!(is_generated_artifact(Path::new("target/debug/artifact")));
7501        assert!(is_generated_artifact(Path::new("dist/cli/index.js")));
7502        assert!(is_generated_artifact(Path::new(
7503            "package/node_modules/dependency/file.js"
7504        )));
7505        assert!(!is_generated_artifact(Path::new(
7506            "distribution/required-package.js"
7507        )));
7508        assert!(!is_generated_artifact(Path::new(
7509            "generated/required-fixture.txt"
7510        )));
7511        assert!(!is_generated_artifact(Path::new("local.env")));
7512    }
7513
7514    struct ReviewFixture {
7515        root: PathBuf,
7516    }
7517
7518    impl Drop for ReviewFixture {
7519        fn drop(&mut self) {
7520            let _ = std::fs::remove_dir_all(&self.root);
7521        }
7522    }
7523
7524    fn test_git(cwd: &Path, args: &[&str]) -> String {
7525        let output = Command::new("git")
7526            .args(args)
7527            .current_dir(cwd)
7528            .output()
7529            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
7530        assert!(
7531            output.status.success(),
7532            "git {args:?} failed: {}",
7533            String::from_utf8_lossy(&output.stderr)
7534        );
7535        String::from_utf8_lossy(&output.stdout).into_owned()
7536    }
7537
7538    fn review_fixture(
7539        tag: &str,
7540        number: i64,
7541    ) -> (ReviewFixture, Repo, PathBuf, WorktreeCheckpoint) {
7542        use std::sync::atomic::{AtomicU32, Ordering};
7543        static NEXT: AtomicU32 = AtomicU32::new(0);
7544        let id = NEXT.fetch_add(1, Ordering::Relaxed);
7545        let root =
7546            std::env::temp_dir().join(format!("spar-repo-test-{tag}-{}-{id}", std::process::id()));
7547        let origin = root.join("origin.git");
7548        let work = root.join("work");
7549        std::fs::create_dir_all(&origin).unwrap();
7550        std::fs::create_dir_all(&work).unwrap();
7551        test_git(&origin, &["init", "--bare", "-b", "main"]);
7552        test_git(&work, &["init", "-b", "main"]);
7553        test_git(&work, &["config", "user.email", "spar@example.invalid"]);
7554        test_git(&work, &["config", "user.name", "spar test"]);
7555        test_git(&work, &["config", "commit.gpgsign", "false"]);
7556        test_git(&work, &["config", "filter.drop.clean", "sed '/^secret:/d'"]);
7557        test_git(&work, &["config", "filter.drop.smudge", "cat"]);
7558        std::fs::write(work.join("README.md"), "seed\n").unwrap();
7559        std::fs::write(work.join("data.txt"), "old\n").unwrap();
7560        std::fs::write(work.join(".gitignore"), "generated/\n").unwrap();
7561        std::fs::write(work.join(".gitattributes"), "* text\n").unwrap();
7562        test_git(&work, &["add", "."]);
7563        test_git(&work, &["commit", "-m", "seed"]);
7564        test_git(
7565            &work,
7566            &["remote", "add", "origin", origin.to_str().unwrap()],
7567        );
7568        test_git(&work, &["push", "-u", "origin", "main"]);
7569        test_git(
7570            &work,
7571            &["push", "origin", &format!("HEAD:refs/pull/{number}/head")],
7572        );
7573        let cfg = crate::config::parse(
7574            "[agents.a]\ncommand = [\"true\"]\n[agents.b]\ncommand = [\"true\"]\n",
7575        )
7576        .unwrap();
7577        let repo = Repo::open(&work, &cfg).unwrap();
7578        let path = repo.worktree_for_pr_head(number).unwrap();
7579        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7580        (ReviewFixture { root }, repo, path, checkpoint)
7581    }
7582
7583    #[test]
7584    fn an_unchanged_review_worktree_is_released_after_a_checked_read() {
7585        let (_fixture, repo, path, checkpoint) = review_fixture("checked-release", 901);
7586
7587        repo.release_review_worktree_checked(901, &checkpoint)
7588            .unwrap();
7589
7590        assert!(!path.exists());
7591    }
7592
7593    #[test]
7594    fn a_branch_reflog_only_commit_prevents_ordinary_deletion() {
7595        let (_fixture, repo, _review, _checkpoint) = review_fixture("branch-reflog", 920);
7596        let (path, branch) = repo.worktree_for_split(45, 1, "main").unwrap();
7597        std::fs::write(path.join("recovery.txt"), "keep me\n").unwrap();
7598        test_git(&path, &["add", "recovery.txt"]);
7599        test_git(&path, &["commit", "-m", "recovery commit"]);
7600        let recovery = test_git(&path, &["rev-parse", "HEAD"]);
7601        test_git(&path, &["reset", "--hard", "main"]);
7602
7603        assert!(!repo.branch_deletion_is_safe(&branch).unwrap());
7604        test_git(
7605            &path,
7606            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7607        );
7608    }
7609
7610    #[test]
7611    fn a_review_ref_reflog_only_commit_prevents_deletion() {
7612        let (_fixture, repo, path, _checkpoint) = review_fixture("review-ref-reflog", 921);
7613        let local_ref = review_ref(921);
7614        let original = test_git(&path, &["rev-parse", &local_ref]);
7615        let tree = test_git(&path, &["rev-parse", "HEAD^{tree}"]);
7616        let recovery = test_git(
7617            &path,
7618            &[
7619                "commit-tree",
7620                tree.trim(),
7621                "-p",
7622                original.trim(),
7623                "-m",
7624                "review ref recovery",
7625            ],
7626        );
7627        test_git(
7628            &path,
7629            &["update-ref", "--create-reflog", &local_ref, recovery.trim()],
7630        );
7631        test_git(
7632            &path,
7633            &["update-ref", &local_ref, original.trim(), recovery.trim()],
7634        );
7635
7636        assert!(!repo.review_ref_deletion_is_safe(921).unwrap());
7637        assert_eq!(original, test_git(&path, &["rev-parse", &local_ref]));
7638        test_git(
7639            &path,
7640            &["cat-file", "-e", &format!("{}^{{commit}}", recovery.trim())],
7641        );
7642    }
7643
7644    #[test]
7645    fn an_unpublished_commit_message_draft_is_recoverable() {
7646        let (_fixture, _repo, path, _checkpoint) = review_fixture("commit-draft", 922);
7647        let raw = PathBuf::from(test_git(&path, &["rev-parse", "--git-dir"]).trim());
7648        let git_dir = if raw.is_absolute() {
7649            raw
7650        } else {
7651            path.join(raw)
7652        };
7653        std::fs::write(git_dir.join("COMMIT_EDITMSG"), "unique recovery draft\n").unwrap();
7654
7655        assert!(repository_has_recoverable_work(&path, true).unwrap());
7656        assert_eq!(
7657            "unique recovery draft\n",
7658            std::fs::read_to_string(git_dir.join("COMMIT_EDITMSG")).unwrap()
7659        );
7660    }
7661
7662    #[test]
7663    fn a_changed_review_worktree_is_retained_after_a_checked_read() {
7664        let (_fixture, repo, path, checkpoint) = review_fixture("checked-dirty", 902);
7665        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
7666
7667        let error = repo
7668            .release_review_worktree_checked(902, &checkpoint)
7669            .unwrap_err();
7670
7671        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7672        assert!(error.to_string().contains("kept for recovery"), "{error}");
7673        assert_eq!(
7674            "recover me\n",
7675            std::fs::read_to_string(path.join("README.md")).unwrap()
7676        );
7677        repo.release_review_worktree(902);
7678    }
7679
7680    #[test]
7681    fn a_review_commit_is_retained_after_a_checked_read() {
7682        let (_fixture, repo, path, checkpoint) = review_fixture("checked-commit", 903);
7683        std::fs::write(path.join("review-note.txt"), "recover me\n").unwrap();
7684        test_git(&path, &["add", "review-note.txt"]);
7685        test_git(&path, &["commit", "-m", "local review recovery"]);
7686        let head = test_git(&path, &["rev-parse", "HEAD"]);
7687
7688        let error = repo
7689            .release_review_worktree_checked(903, &checkpoint)
7690            .unwrap_err();
7691
7692        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7693        assert_eq!(head, test_git(&path, &["rev-parse", "HEAD"]));
7694        assert_eq!(
7695            "recover me\n",
7696            std::fs::read_to_string(path.join("review-note.txt")).unwrap()
7697        );
7698        repo.release_review_worktree(903);
7699    }
7700
7701    /// The read is no longer what refuses: a file the call created and Git was
7702    /// told to ignore is its own output. Removal still refuses, so the file is
7703    /// there to look at, which is the part that mattered.
7704    #[test]
7705    fn an_ignored_review_file_is_retained_after_a_checked_read() {
7706        let (_fixture, repo, path, checkpoint) = review_fixture("checked-ignored", 904);
7707        std::fs::create_dir_all(path.join("generated")).unwrap();
7708        std::fs::write(path.join("generated/recovery.txt"), "recover me\n").unwrap();
7709
7710        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
7711            .expect("its own output is not a change");
7712        let error = repo
7713            .release_review_worktree_checked(904, &checkpoint)
7714            .unwrap_err();
7715
7716        assert!(error.to_string().contains("recoverable"), "{error}");
7717        assert_eq!(
7718            "recover me\n",
7719            std::fs::read_to_string(path.join("generated/recovery.txt")).unwrap()
7720        );
7721        repo.release_review_worktree(904);
7722    }
7723
7724    #[test]
7725    /// The checkpoint no longer refuses this: a path the project ignores is out
7726    /// of scope for a call that only read. What keeps the file is the gate that
7727    /// actually deletes the checkout, which refuses any ignored file outside a
7728    /// known build directory. That is the check with the stake in it, and it is
7729    /// the one that has to hold.
7730    fn a_preexisting_ignored_review_file_change_is_retained() {
7731        let (_fixture, repo, path, _initial) = review_fixture("changed-existing-ignored", 905);
7732        std::fs::create_dir_all(path.join("generated")).unwrap();
7733        let ignored = path.join("generated/recovery.txt");
7734        std::fs::write(&ignored, "before\n").unwrap();
7735        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7736        std::fs::write(&ignored, "after!\n").unwrap();
7737
7738        let error = repo
7739            .release_review_worktree_checked(905, &checkpoint)
7740            .unwrap_err();
7741
7742        assert!(error.to_string().contains("recoverable"), "{error}");
7743        assert!(path.exists());
7744        assert_eq!("after!\n", std::fs::read_to_string(&ignored).unwrap());
7745        repo.release_review_worktree(905);
7746    }
7747
7748    #[test]
7749    fn a_preexisting_ignored_review_file_prevents_checked_removal() {
7750        let (_fixture, repo, path, _initial) = review_fixture("existing-ignored", 906);
7751        std::fs::create_dir_all(path.join("generated")).unwrap();
7752        let ignored = path.join("generated/recovery.txt");
7753        std::fs::write(&ignored, "keep me\n").unwrap();
7754        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7755
7756        let error = repo
7757            .release_review_worktree_checked(906, &checkpoint)
7758            .unwrap_err();
7759
7760        assert!(error.to_string().contains("recoverable"), "{error}");
7761        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7762    }
7763
7764    #[test]
7765    fn overwriting_a_preexisting_untracked_file_is_detected() {
7766        let (_fixture, repo, path, _initial) = review_fixture("changed-untracked", 907);
7767        let untracked = path.join("notes.txt");
7768        std::fs::write(&untracked, "before\n").unwrap();
7769        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
7770        std::fs::write(&untracked, "after!\n").unwrap();
7771
7772        let error = repo
7773            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7774            .unwrap_err();
7775
7776        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7777        assert_eq!("after!\n", std::fs::read_to_string(&untracked).unwrap());
7778    }
7779
7780    #[test]
7781    fn an_assume_unchanged_edit_is_detected() {
7782        let (_fixture, repo, path, checkpoint) = review_fixture("assume-unchanged", 908);
7783        test_git(&path, &["update-index", "--assume-unchanged", "README.md"]);
7784        std::fs::write(path.join("README.md"), "hidden\n").unwrap();
7785
7786        let error = repo
7787            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7788            .unwrap_err();
7789
7790        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7791        assert_eq!(
7792            "hidden\n",
7793            std::fs::read_to_string(path.join("README.md")).unwrap()
7794        );
7795    }
7796
7797    #[test]
7798    fn a_normalized_text_edit_is_detected_even_when_status_is_clean() {
7799        let (_fixture, repo, path, checkpoint) = review_fixture("normalized-text", 909);
7800        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
7801        test_git(&path, &["add", "README.md"]);
7802        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7803
7804        let error = repo
7805            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7806            .unwrap_err();
7807
7808        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7809        assert_eq!(
7810            b"seed\r\n",
7811            std::fs::read(path.join("README.md")).unwrap().as_slice()
7812        );
7813    }
7814
7815    #[cfg(unix)]
7816    #[test]
7817    fn a_mode_edit_is_detected_when_filemode_is_disabled() {
7818        use std::os::unix::fs::PermissionsExt;
7819
7820        let (_fixture, repo, path, checkpoint) = review_fixture("hidden-mode", 910);
7821        test_git(&path, &["config", "core.filemode", "false"]);
7822        let readme = path.join("README.md");
7823        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
7824        permissions.set_mode(0o755);
7825        std::fs::set_permissions(&readme, permissions).unwrap();
7826        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7827
7828        let error = repo
7829            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
7830            .unwrap_err();
7831
7832        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7833        assert_eq!(
7834            0o755,
7835            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
7836        );
7837    }
7838
7839    #[test]
7840    fn a_lossy_filter_cannot_hide_raw_bytes_from_a_managed_commit() {
7841        let (_fixture, repo, path, _checkpoint) = review_fixture("lossy-filter", 911);
7842        std::fs::write(path.join(".gitattributes"), "* text\n*.txt filter=drop\n").unwrap();
7843        test_git(&path, &["add", ".gitattributes"]);
7844        test_git(&path, &["commit", "-m", "select data filter"]);
7845        let baseline = repo.worktree_baseline(&path).unwrap();
7846        std::fs::write(path.join("data.txt"), "secret: recover me\nnew\n").unwrap();
7847
7848        assert!(repo
7849            .commit_pending_changes(&path, &baseline, "change data", "change data")
7850            .unwrap());
7851        let error = repo
7852            .refuse_unrepresented_tracked_changes(&path, &baseline)
7853            .unwrap_err();
7854
7855        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7856        assert_eq!(
7857            "secret: recover me\nnew\n",
7858            std::fs::read_to_string(path.join("data.txt")).unwrap()
7859        );
7860        assert_eq!("new\n", test_git(&path, &["show", "HEAD:data.txt"]));
7861    }
7862
7863    #[test]
7864    fn a_baseline_ordinary_untracked_file_is_not_staged_by_a_managed_commit() {
7865        let (_fixture, repo, path, _checkpoint) = review_fixture("baseline-untracked", 927);
7866        std::fs::create_dir_all(path.join("target")).unwrap();
7867        let untracked = path.join("target/user.yaml");
7868        std::fs::write(&untracked, "user data\n").unwrap();
7869        let baseline = repo.worktree_baseline(&path).unwrap();
7870        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7871
7872        assert!(repo
7873            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7874            .unwrap());
7875
7876        assert_eq!("user data\n", std::fs::read_to_string(&untracked).unwrap());
7877        assert_eq!(
7878            "?? target/user.yaml\n",
7879            test_git(&path, &["status", "--short", "--untracked-files=all"])
7880        );
7881        assert!(test_git(
7882            &path,
7883            &[
7884                "ls-tree",
7885                "-r",
7886                "--name-only",
7887                "HEAD",
7888                "--",
7889                "target/user.yaml"
7890            ]
7891        )
7892        .is_empty());
7893    }
7894
7895    #[test]
7896    fn changing_a_baseline_ordinary_untracked_file_stops_a_managed_commit() {
7897        let (_fixture, repo, path, _checkpoint) = review_fixture("changed-untracked", 929);
7898        std::fs::create_dir_all(path.join("target")).unwrap();
7899        let untracked = path.join("target/user.yaml");
7900        std::fs::write(&untracked, "before\n").unwrap();
7901        let baseline = repo.worktree_baseline(&path).unwrap();
7902        let before = test_git(&path, &["rev-parse", "HEAD"]);
7903        std::fs::write(&untracked, "after\n").unwrap();
7904        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7905
7906        let error = repo
7907            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7908            .unwrap_err();
7909
7910        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7911        assert!(error.to_string().contains("target/user.yaml"), "{error}");
7912        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7913        assert!(test_git(&path, &["diff", "--cached", "--name-only"]).is_empty());
7914        assert_eq!("after\n", std::fs::read_to_string(&untracked).unwrap());
7915    }
7916
7917    #[test]
7918    fn a_new_ordinary_untracked_file_is_staged_by_a_managed_commit() {
7919        let (_fixture, repo, path, _checkpoint) = review_fixture("new-untracked", 928);
7920        let baseline = repo.worktree_baseline(&path).unwrap();
7921        std::fs::create_dir_all(path.join("target")).unwrap();
7922        std::fs::write(path.join("target/new.txt"), "new file\n").unwrap();
7923
7924        assert!(repo
7925            .commit_pending_changes(&path, &baseline, "add file", "add file")
7926            .unwrap());
7927
7928        assert_eq!(
7929            "new file\n",
7930            test_git(&path, &["show", "HEAD:target/new.txt"])
7931        );
7932        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
7933    }
7934
7935    #[test]
7936    fn deleting_existing_ignored_work_stops_a_managed_commit() {
7937        let (_fixture, repo, path, _checkpoint) = review_fixture("deleted-ignored", 912);
7938        std::fs::create_dir_all(path.join("generated")).unwrap();
7939        let ignored = path.join("generated/keep.txt");
7940        std::fs::write(&ignored, "user data\n").unwrap();
7941        let baseline = repo.worktree_baseline(&path).unwrap();
7942        let before = test_git(&path, &["rev-parse", "HEAD"]);
7943        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7944        std::fs::remove_file(&ignored).unwrap();
7945
7946        let error = repo
7947            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7948            .unwrap_err();
7949
7950        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7951        assert!(error.to_string().contains("existing untracked"), "{error}");
7952        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7953        assert_eq!(
7954            "tracked change\n",
7955            std::fs::read_to_string(path.join("README.md")).unwrap()
7956        );
7957    }
7958
7959    #[test]
7960    fn new_ignored_work_stops_a_managed_commit_with_tracked_changes() {
7961        let (_fixture, repo, path, _checkpoint) = review_fixture("mixed-ignored", 926);
7962        let baseline = repo.worktree_baseline(&path).unwrap();
7963        let before = test_git(&path, &["rev-parse", "HEAD"]);
7964        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
7965        std::fs::create_dir_all(path.join("generated")).unwrap();
7966        let ignored = path.join("generated/recovery.txt");
7967        std::fs::write(&ignored, "keep me\n").unwrap();
7968
7969        let error = repo
7970            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
7971            .unwrap_err();
7972
7973        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
7974        assert!(error.to_string().contains("recovery.txt"), "{error}");
7975        assert_eq!(before, test_git(&path, &["rev-parse", "HEAD"]));
7976        assert_eq!("keep me\n", std::fs::read_to_string(&ignored).unwrap());
7977        assert!(test_git(&path, &["status", "--porcelain"])
7978            .lines()
7979            .any(|line| line == "M  README.md"));
7980    }
7981
7982    #[test]
7983    fn an_lf_override_of_an_expected_crlf_checkout_is_recoverable() {
7984        let (_fixture, _repo, path, _checkpoint) = review_fixture("lf-override", 913);
7985        test_git(&path, &["config", "core.autocrlf", "true"]);
7986        std::fs::write(path.join("README.md"), "seed\n").unwrap();
7987        assert_eq!(
7988            test_git(&path, &["hash-object", "README.md"]).trim(),
7989            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
7990        );
7991
7992        assert!(repository_has_recoverable_work(&path, true).unwrap());
7993        assert_eq!(
7994            "seed\n",
7995            std::fs::read_to_string(path.join("README.md")).unwrap()
7996        );
7997    }
7998
7999    #[test]
8000    fn autocrlf_input_overrides_a_crlf_core_eol() {
8001        let (_fixture, _repo, path, _checkpoint) = review_fixture("autocrlf-input", 923);
8002        test_git(&path, &["config", "core.autocrlf", "input"]);
8003        test_git(&path, &["config", "core.eol", "crlf"]);
8004        std::fs::write(path.join("README.md"), b"seed\r\n").unwrap();
8005        assert_eq!(
8006            test_git(&path, &["hash-object", "README.md"]).trim(),
8007            test_git(&path, &["rev-parse", "HEAD:README.md"]).trim()
8008        );
8009
8010        assert!(repository_has_recoverable_work(&path, true).unwrap());
8011        assert_eq!(
8012            b"seed\r\n",
8013            std::fs::read(path.join("README.md")).unwrap().as_slice()
8014        );
8015    }
8016
8017    #[cfg(unix)]
8018    #[test]
8019    fn a_non_executable_permission_change_is_recoverable() {
8020        use std::os::unix::fs::PermissionsExt;
8021
8022        let (_fixture, repo, path, checkpoint) = review_fixture("permission-change", 924);
8023        let readme = path.join("README.md");
8024        let mut permissions = std::fs::metadata(&readme).unwrap().permissions();
8025        permissions.set_mode(0o600);
8026        std::fs::set_permissions(&readme, permissions).unwrap();
8027        assert!(test_git(&path, &["status", "--porcelain"]).is_empty());
8028
8029        let error = repo
8030            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8031            .unwrap_err();
8032
8033        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8034        assert!(repository_has_recoverable_work(&path, true).unwrap());
8035        assert_eq!(
8036            0o600,
8037            std::fs::metadata(&readme).unwrap().permissions().mode() & 0o777
8038        );
8039    }
8040
8041    #[cfg(unix)]
8042    #[test]
8043    fn a_managed_commit_skips_signing_and_hooks() {
8044        use std::os::unix::fs::PermissionsExt;
8045
8046        let (fixture, repo, path, _checkpoint) = review_fixture("managed-commit", 925);
8047        let common = common_git_dir(&path).unwrap();
8048        let hook = common.join("hooks/pre-commit");
8049        let marker = fixture.root.join("hook-ran");
8050        std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
8051        std::fs::write(
8052            &hook,
8053            format!(
8054                "#!/bin/sh\nprintf ran > {}\nexit 1\n",
8055                sh_quote(marker.to_str().unwrap())
8056            ),
8057        )
8058        .unwrap();
8059        let mut permissions = std::fs::metadata(&hook).unwrap().permissions();
8060        permissions.set_mode(0o755);
8061        std::fs::set_permissions(&hook, permissions).unwrap();
8062        test_git(&path, &["config", "commit.gpgsign", "true"]);
8063        test_git(&path, &["config", "gpg.program", "/usr/bin/false"]);
8064        std::fs::write(path.join("managed.txt"), "managed\n").unwrap();
8065        test_git(&path, &["add", "managed.txt"]);
8066
8067        repo.commit_staged_changes(&path, "record managed change")
8068            .unwrap();
8069
8070        assert!(!marker.exists());
8071        assert_eq!("managed\n", test_git(&path, &["show", "HEAD:managed.txt"]));
8072    }
8073
8074    #[test]
8075    fn an_auto_text_checkout_is_retained_when_representation_is_ambiguous() {
8076        let (_fixture, _repo, path, _checkpoint) = review_fixture("auto-text", 914);
8077        std::fs::write(
8078            path.join(".gitattributes"),
8079            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md text=auto\n",
8080        )
8081        .unwrap();
8082        test_git(&path, &["add", ".gitattributes"]);
8083        test_git(&path, &["commit", "-m", "select automatic text"]);
8084        test_git(&path, &["config", "core.autocrlf", "true"]);
8085
8086        assert!(repository_has_recoverable_work(&path, true).unwrap());
8087    }
8088
8089    #[test]
8090    fn an_ident_checkout_is_retained_even_when_raw_bytes_match_the_index() {
8091        let (_fixture, _repo, path, _checkpoint) = review_fixture("ident", 915);
8092        std::fs::write(
8093            path.join(".gitattributes"),
8094            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md -text ident\n",
8095        )
8096        .unwrap();
8097        test_git(&path, &["add", ".gitattributes"]);
8098        test_git(&path, &["commit", "-m", "select ident expansion"]);
8099        std::fs::write(path.join("README.md"), "seed\n").unwrap();
8100
8101        assert!(repository_has_recoverable_work(&path, true).unwrap());
8102    }
8103
8104    /// Add ignore rules the way SPAR does, without a tracked change the
8105    /// worktree would then be kept for.
8106    /// The failure this whole rule was rewritten for: a reviewer ran the
8107    /// project's binding generator to check a finding, and the lockfile it left
8108    /// beside its own manifest matched no directory name spar knew.
8109    #[test]
8110    fn a_lockfile_a_generator_left_outside_build_output_is_the_calls_own_output() {
8111        let (_fixture, repo, path, _initial) = review_fixture("generator-lockfile", 940);
8112        exclude_paths(&repo, &["target/", "bindings/uniffi-bindgen/Cargo.lock"]);
8113        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8114        std::fs::create_dir_all(path.join("bindings/uniffi-bindgen")).unwrap();
8115        std::fs::write(
8116            path.join("bindings/uniffi-bindgen/Cargo.lock"),
8117            "# generated\n",
8118        )
8119        .unwrap();
8120
8121        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8122            .expect("a path the project ignores is the call's own output");
8123    }
8124
8125    #[test]
8126    fn an_attribute_file_under_build_output_is_not_a_filter_change() {
8127        let (_fixture, repo, path, _initial) = review_fixture("ignored-attributes", 941);
8128        exclude_paths(&repo, &["target/"]);
8129        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8130        std::fs::create_dir_all(path.join("target/package")).unwrap();
8131        std::fs::write(path.join("target/package/.gitattributes"), "* -text\n").unwrap();
8132
8133        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8134            .expect("an ignored attribute file governs only ignored paths");
8135    }
8136
8137    /// Two reviewers share one checkout. The marker written when the first
8138    /// one's answer was refused used to be a brand new untracked file in the
8139    /// snapshot the second one was compared against.
8140    #[test]
8141    fn a_recovery_marker_is_not_a_change_to_the_worktree() {
8142        let (_fixture, repo, path, _initial) = review_fixture("marker-snapshot", 942);
8143        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8144        let _ = uncertain_worktree_change(&path, "an earlier call was refused");
8145
8146        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8147            .expect("spar's own marker is not something a call did");
8148    }
8149
8150    #[test]
8151    fn a_path_that_vanishes_between_listing_and_stat_does_not_fail_the_probe() {
8152        let (_fixture, _repo, path, _initial) = review_fixture("vanishing-path", 943);
8153
8154        let gone = ignored_file_fingerprint(&path.join("deleted-mid-build"))
8155            .expect("a path a build removed is not an unreadable worktree");
8156
8157        assert_eq!(vanished_untracked_file(), gone);
8158    }
8159
8160    /// A path that goes is still a path that was there. Dropping it from the
8161    /// snapshot would let it pass for one the call never touched.
8162    #[test]
8163    fn a_vanished_path_is_not_recorded_as_absent() {
8164        let (_fixture, repo, path, _initial) = review_fixture("vanished-not-absent", 944);
8165        std::fs::write(path.join("notes.txt"), "before\n").unwrap();
8166        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8167        std::fs::remove_file(path.join("notes.txt")).unwrap();
8168
8169        let error = repo
8170            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8171            .unwrap_err();
8172
8173        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8174    }
8175
8176    fn exclude_paths(repo: &Repo, lines: &[&str]) {
8177        use std::io::Write;
8178        let path = repo.root().join(".git").join("info").join("exclude");
8179        let mut file = std::fs::OpenOptions::new()
8180            .create(true)
8181            .append(true)
8182            .open(&path)
8183            .unwrap();
8184        for line in lines {
8185            writeln!(file, "{line}").unwrap();
8186        }
8187    }
8188
8189    #[test]
8190    fn a_replaced_generated_session_does_not_stop_a_managed_commit() {
8191        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-commit", 942);
8192        exclude_paths(&repo, &["dist/"]);
8193        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8194        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8195        let baseline = repo.worktree_baseline(&path).unwrap();
8196        std::fs::write(path.join("README.md"), "tracked change\n").unwrap();
8197        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8198        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8199        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8200
8201        assert!(repo
8202            .commit_pending_changes(&path, &baseline, "change readme", "change readme")
8203            .unwrap());
8204        assert_eq!(
8205            "tracked change\n",
8206            std::fs::read_to_string(path.join("README.md")).unwrap()
8207        );
8208    }
8209
8210    #[test]
8211    fn a_rebuild_that_replaces_a_generated_session_does_not_stop_a_call() {
8212        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-session", 941);
8213        exclude_paths(&repo, &["dist/"]);
8214        std::fs::create_dir_all(path.join("dist/session-old")).unwrap();
8215        std::fs::write(path.join("dist/session-old/dep-graph.bin"), "old\n").unwrap();
8216        std::fs::write(path.join("dist/session-old.lock"), "").unwrap();
8217        let baseline = repo.worktree_baseline(&path).unwrap();
8218        std::fs::remove_dir_all(path.join("dist/session-old")).unwrap();
8219        std::fs::remove_file(path.join("dist/session-old.lock")).unwrap();
8220        std::fs::create_dir_all(path.join("dist/session-new")).unwrap();
8221        std::fs::write(path.join("dist/session-new/dep-graph.bin"), "new\n").unwrap();
8222
8223        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8224        repo.refuse_changed_existing_untracked(&path, &baseline)
8225            .unwrap();
8226    }
8227
8228    #[test]
8229    fn rebuilt_output_does_not_stop_a_call_that_committed_nothing() {
8230        let (_fixture, repo, path, _checkpoint) = review_fixture("rebuilt-output", 939);
8231        exclude_paths(&repo, &["dist/"]);
8232        std::fs::create_dir_all(path.join("dist")).unwrap();
8233        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8234        let baseline = repo.worktree_baseline(&path).unwrap();
8235        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8236        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8237
8238        repo.refuse_new_ignored_files(&path, &baseline).unwrap();
8239        repo.refuse_changed_existing_untracked(&path, &baseline)
8240            .unwrap();
8241    }
8242
8243    #[test]
8244    fn a_new_ignored_file_outside_build_output_still_stops_a_call() {
8245        let (_fixture, repo, path, _checkpoint) = review_fixture("new-ignored-local", 940);
8246        exclude_paths(&repo, &["dist/", ".env.local"]);
8247        std::fs::create_dir_all(path.join("dist")).unwrap();
8248        let baseline = repo.worktree_baseline(&path).unwrap();
8249        std::fs::write(path.join("dist/index.js"), "a build\n").unwrap();
8250        std::fs::write(path.join(".env.local"), "TOKEN=x\n").unwrap();
8251
8252        let error = repo.refuse_new_ignored_files(&path, &baseline).unwrap_err();
8253
8254        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8255        assert!(error.to_string().contains(".env.local"), "{error}");
8256    }
8257
8258    #[test]
8259    fn a_read_only_inspection_may_rebuild_generated_output() {
8260        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-build", 937);
8261        exclude_paths(&repo, &["dist/"]);
8262        std::fs::create_dir_all(path.join("dist")).unwrap();
8263        std::fs::write(path.join("dist/index.js"), "first build\n").unwrap();
8264        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8265        std::fs::write(path.join("dist/index.js"), "second build\n").unwrap();
8266        std::fs::write(path.join("dist/extra.js"), "more output\n").unwrap();
8267
8268        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8269            .unwrap();
8270    }
8271
8272    #[test]
8273    fn a_read_only_inspection_may_change_an_ignored_file_elsewhere() {
8274        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-local", 938);
8275        exclude_paths(&repo, &["dist/", ".env.local"]);
8276        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
8277        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8278        std::fs::write(path.join(".env.local"), "TOKEN=after\n").unwrap();
8279
8280        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8281            .unwrap();
8282    }
8283
8284    #[test]
8285    fn a_read_only_inspection_may_delete_an_ignored_file_elsewhere() {
8286        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-deleted", 939);
8287        exclude_paths(&repo, &["dist/", ".env.local"]);
8288        std::fs::write(path.join(".env.local"), "TOKEN=before\n").unwrap();
8289        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8290        std::fs::remove_file(path.join(".env.local")).unwrap();
8291
8292        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8293            .unwrap();
8294    }
8295
8296    #[test]
8297    fn a_read_only_inspection_may_not_change_an_ordinary_untracked_file() {
8298        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-ordinary", 940);
8299        exclude_paths(&repo, &["dist/"]);
8300        std::fs::write(path.join("notes.md"), "before\n").unwrap();
8301        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8302        std::fs::write(path.join("notes.md"), "after\n").unwrap();
8303
8304        let error = repo
8305            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8306            .unwrap_err();
8307
8308        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8309        assert!(error.to_string().contains("notes.md"), "{error}");
8310    }
8311
8312    #[test]
8313    fn an_ignored_file_that_becomes_ordinary_stops_a_read_only_inspection() {
8314        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-unignored", 941);
8315        exclude_paths(&repo, &["dist/", "local.env"]);
8316        std::fs::write(path.join("local.env"), "TOKEN=before\n").unwrap();
8317        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8318        // Stop ignoring it, so the same bytes are now an ordinary untracked
8319        // file. What a path is counts, not only whether it moved.
8320        std::fs::write(
8321            repo.root().join(".git").join("info").join("exclude"),
8322            "dist/\n",
8323        )
8324        .unwrap();
8325
8326        let error = repo
8327            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8328            .unwrap_err();
8329
8330        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8331        assert!(error.to_string().contains("local.env"), "{error}");
8332    }
8333
8334    #[test]
8335    fn a_read_only_inspection_names_the_tracked_file_that_moved() {
8336        let (_fixture, repo, path, _checkpoint) = review_fixture("inspect-tracked", 942);
8337        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8338        std::fs::write(path.join("README.md"), "recover me\n").unwrap();
8339
8340        let error = repo
8341            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8342            .unwrap_err();
8343
8344        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8345        assert!(error.to_string().contains("README.md"), "{error}");
8346    }
8347
8348    #[test]
8349    fn build_output_alone_does_not_keep_a_worktree() {
8350        let (_fixture, repo, path, _checkpoint) = review_fixture("build-output", 933);
8351        exclude_paths(&repo, &["target/", "dist/"]);
8352        std::fs::create_dir_all(path.join("target/debug")).unwrap();
8353        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8354        std::fs::create_dir_all(path.join("dist/cli")).unwrap();
8355        std::fs::write(path.join("dist/cli/index.js"), "typescript output\n").unwrap();
8356
8357        assert!(!repository_has_recoverable_work(&path, true).unwrap());
8358        repo.release_review_worktree(933);
8359
8360        assert!(!path.exists());
8361    }
8362
8363    #[test]
8364    fn an_ignored_file_outside_build_output_keeps_a_worktree() {
8365        let (_fixture, repo, path, _checkpoint) = review_fixture("ignored-local", 934);
8366        exclude_paths(&repo, &["target/", ".env.local"]);
8367        std::fs::create_dir_all(path.join("target/debug")).unwrap();
8368        std::fs::write(path.join("target/debug/artifact"), "compiler output\n").unwrap();
8369        std::fs::write(path.join(".env.local"), "TOKEN=keep me\n").unwrap();
8370
8371        assert!(repository_has_recoverable_work(&path, true).unwrap());
8372        repo.release_review_worktree(934);
8373
8374        assert_eq!(
8375            "TOKEN=keep me\n",
8376            std::fs::read_to_string(path.join(".env.local")).unwrap()
8377        );
8378    }
8379
8380    #[test]
8381    fn a_repository_nested_in_build_output_keeps_a_worktree() {
8382        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-in-build", 935);
8383        exclude_paths(&repo, &["node_modules/"]);
8384        let nested = path.join("node_modules/local-dep");
8385        std::fs::create_dir_all(&nested).unwrap();
8386        test_git(&nested, &["init"]);
8387        std::fs::write(nested.join("work.txt"), "uncommitted\n").unwrap();
8388
8389        assert!(repository_has_recoverable_work(&path, true).unwrap());
8390        repo.release_review_worktree(935);
8391
8392        assert!(nested.join(".git").exists());
8393    }
8394
8395    #[test]
8396    fn an_ordinary_untracked_file_keeps_a_worktree() {
8397        let (_fixture, repo, path, _checkpoint) = review_fixture("ordinary-untracked", 936);
8398        std::fs::write(path.join("notes.md"), "somebody's notes\n").unwrap();
8399
8400        assert!(repository_has_recoverable_work(&path, true).unwrap());
8401        repo.release_review_worktree(936);
8402
8403        assert_eq!(
8404            "somebody's notes\n",
8405            std::fs::read_to_string(path.join("notes.md")).unwrap()
8406        );
8407    }
8408
8409    #[test]
8410    fn a_legacy_crlf_checkout_is_retained_conservatively() {
8411        let (_fixture, _repo, path, _checkpoint) = review_fixture("legacy-crlf", 916);
8412        std::fs::write(
8413            path.join(".gitattributes"),
8414            ".gitattributes -text\n.gitignore -text\ndata.txt -text\nREADME.md crlf\n",
8415        )
8416        .unwrap();
8417        test_git(&path, &["add", ".gitattributes"]);
8418        test_git(&path, &["commit", "-m", "select legacy line endings"]);
8419
8420        assert!(repository_has_recoverable_work(&path, true).unwrap());
8421    }
8422
8423    #[test]
8424    fn a_nested_git_entry_inside_a_tracked_directory_is_recoverable() {
8425        let (_fixture, repo, path, _checkpoint) = review_fixture("nested-git", 917);
8426        let nested = path.join("tracked");
8427        std::fs::create_dir_all(&nested).unwrap();
8428        std::fs::write(nested.join("seed.txt"), "seed\n").unwrap();
8429        test_git(&path, &["add", "tracked/seed.txt"]);
8430        test_git(&path, &["commit", "-m", "add tracked directory"]);
8431        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8432        test_git(&nested, &["init"]);
8433
8434        let error = repo
8435            .require_unchanged_worktree(&path, &checkpoint, "review worktree")
8436            .unwrap_err();
8437
8438        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8439        assert!(error.to_string().contains("Git entry"), "{error}");
8440        assert!(nested.join(".git").exists());
8441    }
8442
8443    /// A reviewer that clones a reproduction into ignored build output used to
8444    /// lose the whole review. The state check refused every Git entry it had
8445    /// not put there, including the one Git reports as a nested checkout and
8446    /// the untracked snapshot already records by identity.
8447    #[test]
8448    fn a_repository_cloned_into_build_output_leaves_the_state_readable() {
8449        let (_fixture, repo, path, _checkpoint) = review_fixture("cloned-repro", 918);
8450        exclude_paths(&repo, &["target/"]);
8451        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8452        let repro = path.join("target/pr918-repro");
8453        std::fs::create_dir_all(&repro).unwrap();
8454        test_git(&repro, &["init"]);
8455        std::fs::write(repro.join("main.rs"), "fn main() {}\n").unwrap();
8456
8457        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8458            .unwrap();
8459
8460        assert!(repository_has_recoverable_work(&path, true).unwrap());
8461        assert!(repro.join(".git").exists());
8462    }
8463
8464    /// What sits under a nested checkout is that repository's business, so the
8465    /// scan stops at its directory rather than reading a checkout of its own
8466    /// as an entry the outer worktree has to account for.
8467    #[test]
8468    fn a_repository_inside_a_nested_checkout_is_left_alone() {
8469        let (_fixture, repo, path, _checkpoint) = review_fixture("checkout-in-checkout", 919);
8470        exclude_paths(&repo, &["target/"]);
8471        let checkpoint = repo.worktree_checkpoint(&path).unwrap();
8472        let repro = path.join("target/repro");
8473        let vendored = repro.join("vendor/dep");
8474        std::fs::create_dir_all(&vendored).unwrap();
8475        test_git(&repro, &["init"]);
8476        test_git(&vendored, &["init"]);
8477
8478        repo.require_unchanged_worktree(&path, &checkpoint, "review worktree")
8479            .unwrap();
8480
8481        assert!(vendored.join(".git").exists());
8482    }
8483
8484    #[test]
8485    fn a_resident_worktree_is_snapshotted_as_one_ignored_entry() {
8486        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-snapshot", 930);
8487
8488        let state = ignored_untracked_state(repo.root()).unwrap();
8489
8490        let relative = path.strip_prefix(repo.root()).unwrap();
8491        assert!(
8492            state.files.contains_key(relative),
8493            "{:?}",
8494            state.files.keys().collect::<Vec<_>>()
8495        );
8496        assert!(state.is_ignored(relative));
8497    }
8498
8499    #[test]
8500    fn work_inside_a_resident_worktree_leaves_the_outer_baseline_alone() {
8501        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-churn", 931);
8502        let baseline = repo.worktree_baseline(repo.root()).unwrap();
8503        std::fs::write(path.join("scratch.txt"), "another run's work\n").unwrap();
8504        std::fs::write(path.join("README.md"), "another run's edit\n").unwrap();
8505
8506        repo.refuse_new_ignored_files(repo.root(), &baseline)
8507            .unwrap();
8508        repo.refuse_changed_existing_untracked(repo.root(), &baseline)
8509            .unwrap();
8510    }
8511
8512    #[test]
8513    fn deleting_a_resident_worktree_during_a_call_is_refused() {
8514        let (_fixture, repo, path, _checkpoint) = review_fixture("resident-deleted", 932);
8515        let baseline = repo.worktree_baseline(repo.root()).unwrap();
8516        std::fs::remove_dir_all(&path).unwrap();
8517
8518        let error = repo
8519            .refuse_new_ignored_files(repo.root(), &baseline)
8520            .unwrap_err();
8521
8522        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8523        assert!(error.to_string().contains("review-932"), "{error}");
8524    }
8525
8526    #[test]
8527    fn a_nested_repository_record_is_read_as_a_plain_path() {
8528        let (path, nested) = untracked_record(b"vendor/checkout/", "untracked").unwrap();
8529        assert_eq!(Path::new("vendor/checkout"), path);
8530        assert!(nested);
8531
8532        let (path, nested) = untracked_record(b"vendor/notes.txt", "untracked").unwrap();
8533        assert_eq!(Path::new("vendor/notes.txt"), path);
8534        assert!(!nested);
8535
8536        assert!(untracked_record(b"/", "untracked").is_err());
8537    }
8538
8539    #[cfg(unix)]
8540    #[test]
8541    fn a_non_utf8_git_path_is_preserved_without_loss() {
8542        use std::os::unix::ffi::OsStrExt;
8543
8544        let path = path_from_git_bytes(&[b'f', 0xff]).unwrap();
8545
8546        assert_eq!(&[b'f', 0xff], path.as_os_str().as_bytes());
8547    }
8548
8549    #[test]
8550    fn guarded_merge_pins_the_reviewed_head() {
8551        let args = merge_pr_args("36", Some("abc123"), true);
8552        assert_eq!(
8553            vec![
8554                "pr",
8555                "merge",
8556                "36",
8557                "--squash",
8558                "--delete-branch",
8559                "--match-head-commit",
8560                "abc123"
8561            ],
8562            args
8563        );
8564    }
8565
8566    #[test]
8567    fn an_ambiguous_create_is_success_when_the_pull_request_exists() {
8568        let pr = PrRef {
8569            number: 7,
8570            url: "https://example.test/pull/7".into(),
8571            title: "part one".into(),
8572        };
8573        let result = reconcile_pr_creation(
8574            "split-34-1",
8575            Err(crate::error::SparError::new("connection lost")),
8576            Ok(Some(pr)),
8577        )
8578        .unwrap();
8579        assert_eq!(7, result.number);
8580    }
8581
8582    #[test]
8583    fn a_failed_create_keeps_its_original_error_when_no_pr_exists() {
8584        let error = reconcile_pr_creation(
8585            "split-34-1",
8586            Err(crate::error::SparError::new("permission denied")),
8587            Ok(None),
8588        )
8589        .unwrap_err();
8590        assert!(error.to_string().contains("permission denied"), "{error}");
8591    }
8592
8593    #[test]
8594    fn a_pull_request_against_the_wrong_base_does_not_reconcile_creation() {
8595        let text = r#"[{"number":7,"url":"https://example.test/pull/7","title":"part one","baseRefName":"main"}]"#;
8596        assert!(pr_for_base(text, "split-34-2", "split-34-1")
8597            .unwrap()
8598            .is_none());
8599        let found = pr_for_base(text, "split-34-2", "main").unwrap().unwrap();
8600        assert_eq!(7, found.number);
8601    }
8602
8603    #[test]
8604    fn an_ambiguous_comment_is_success_when_the_exact_body_exists() {
8605        let result = reconcile_comment_post(
8606            34,
8607            "the summary",
8608            crate::error::SparError::new("connection lost"),
8609            Ok(vec![serde_json::json!({"body": "the summary"})]),
8610        );
8611        assert!(result.is_ok(), "{result:?}");
8612    }
8613
8614    #[test]
8615    fn an_ambiguous_comment_preserves_failure_when_only_other_text_exists() {
8616        let error = reconcile_comment_post(
8617            34,
8618            "the summary",
8619            crate::error::SparError::new("connection lost"),
8620            Ok(vec![serde_json::json!({"body": "<!-- spar:split -->"})]),
8621        )
8622        .unwrap_err();
8623        assert_eq!("connection lost", error.to_string());
8624    }
8625
8626    #[test]
8627    fn an_ambiguous_comment_reports_an_unverifiable_lookup() {
8628        let error = reconcile_comment_post(
8629            34,
8630            "the summary",
8631            crate::error::SparError::new("connection lost"),
8632            Err(crate::error::SparError::new("comments unavailable")),
8633        )
8634        .unwrap_err();
8635        assert!(
8636            error.to_string().contains("could not be verified"),
8637            "{error}"
8638        );
8639        assert!(
8640            error.to_string().contains("comments unavailable"),
8641            "{error}"
8642        );
8643        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8644        assert!(!error.worth_retrying());
8645    }
8646
8647    #[test]
8648    fn an_ambiguous_issue_edit_is_success_when_the_wanted_body_exists() {
8649        let result = reconcile_issue_edit(
8650            34,
8651            "wanted body",
8652            crate::error::SparError::new("connection lost"),
8653            Ok("wanted body".to_string()),
8654        );
8655        assert!(result.is_ok(), "{result:?}");
8656    }
8657
8658    #[test]
8659    fn an_ambiguous_issue_edit_reports_an_unverifiable_lookup() {
8660        let error = reconcile_issue_edit(
8661            34,
8662            "wanted body",
8663            crate::error::SparError::new("connection lost"),
8664            Err(crate::error::SparError::new("issue unavailable")),
8665        )
8666        .unwrap_err();
8667        assert!(
8668            error.to_string().contains("could not be verified"),
8669            "{error}"
8670        );
8671        assert!(error.to_string().contains("issue unavailable"), "{error}");
8672        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8673        assert!(!error.worth_retrying());
8674    }
8675
8676    #[test]
8677    fn an_ambiguous_issue_creation_recovers_the_exact_issue() {
8678        let found = ExistingIssue {
8679            number: 101,
8680            url: "https://example.test/issues/101".into(),
8681            title: "child".into(),
8682            body: "body".into(),
8683            open: true,
8684        };
8685        let url = reconcile_issue_creation(
8686            "child",
8687            Err(crate::error::SparError::new("connection lost")),
8688            Ok(Some(found)),
8689        )
8690        .unwrap();
8691        assert_eq!("https://example.test/issues/101", url);
8692    }
8693
8694    #[test]
8695    fn a_failed_issue_creation_keeps_its_error_when_no_issue_exists() {
8696        let error = reconcile_issue_creation(
8697            "child",
8698            Err(crate::error::SparError::new("permission denied")),
8699            Ok(None),
8700        )
8701        .unwrap_err();
8702        assert!(error.to_string().contains("permission denied"), "{error}");
8703    }
8704
8705    #[test]
8706    fn an_unverifiable_issue_creation_is_marked_uncertain() {
8707        let error = reconcile_issue_creation(
8708            "child",
8709            Err(crate::error::SparError::new("connection lost")),
8710            Err(crate::error::SparError::new("issues unavailable")),
8711        )
8712        .unwrap_err();
8713        assert_eq!(crate::error::ErrorKind::UncertainWrite, error.kind());
8714        assert!(!error.worth_retrying());
8715    }
8716
8717    #[test]
8718    fn an_ambiguous_split_push_is_success_when_origin_has_local_head() {
8719        let result = reconcile_failed_split_push(
8720            "split-34-1",
8721            crate::error::SparError::new("connection lost"),
8722            Ok("abc123\n".into()),
8723            Ok("abc123\trefs/heads/split-34-1\n".into()),
8724        );
8725        assert!(result.is_ok(), "{result:?}");
8726    }
8727
8728    #[test]
8729    fn a_split_push_collision_is_definite_and_never_overwrites() {
8730        let error = reconcile_failed_split_push(
8731            "split-34-1",
8732            crate::error::SparError::new("lease rejected"),
8733            Ok("abc123\n".into()),
8734            Ok("def456\trefs/heads/split-34-1\n".into()),
8735        )
8736        .unwrap_err();
8737        assert!(!error.retain_worktree());
8738        assert!(
8739            error.to_string().contains("Nothing was overwritten"),
8740            "{error}"
8741        );
8742    }
8743
8744    #[test]
8745    fn an_unreadable_split_push_result_keeps_the_worktree() {
8746        let error = reconcile_failed_split_push(
8747            "split-34-1",
8748            crate::error::SparError::new("connection lost"),
8749            Ok("abc123\n".into()),
8750            Err(crate::error::SparError::new("origin unavailable")),
8751        )
8752        .unwrap_err();
8753        assert!(error.retain_worktree());
8754        assert!(error.to_string().contains("could not confirm"), "{error}");
8755    }
8756
8757    /// Follow-up deduplication compares a title it computed against the title
8758    /// GitHub stored. If those two transforms can disagree, the check never
8759    /// matches and every review round files another copy of the same issue.
8760    #[test]
8761    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
8762        let repo = repo_for_titles();
8763        for raw in [
8764            "Retry loop spins \u{2014} Retry-After parses to zero",
8765            "plain title",
8766            "  spread   over\nlines  ",
8767            "\u{1F916} Generated with something",
8768            &format!("a \u{2014} {}", "very long title ".repeat(20)),
8769            &"x".repeat(300),
8770            &format!("{} \u{2014} end", "y".repeat(88)),
8771            // Exactly the budget, with two spaceless dashes. The scrub turns
8772            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
8773            // character over budget per dash and a second pass clips again,
8774            // producing a different string. Scrub-then-clip cannot.
8775            &{
8776                let tail = "a\u{2014}b c\u{2014}d";
8777                let pad = Style::default().max_title_chars - tail.chars().count();
8778                format!("{}{tail}", "w".repeat(pad))
8779            },
8780        ] {
8781            let once = repo.clean_title(raw).unwrap();
8782            let twice = repo.clean_title(&once).unwrap();
8783            assert_eq!(once, twice, "not idempotent for {raw:?}");
8784            assert!(
8785                once.chars().count() <= repo.style.max_title_chars,
8786                "over budget: {once:?}"
8787            );
8788            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
8789        }
8790    }
8791
8792    #[test]
8793    fn a_title_with_an_em_dash_survives_as_readable_text() {
8794        let repo = repo_for_titles();
8795        assert_eq!(
8796            "Retry loop spins, Retry-After parses to zero",
8797            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
8798                .unwrap()
8799        );
8800    }
8801
8802    #[test]
8803    fn sh_quote_survives_a_quote() {
8804        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
8805    }
8806
8807    #[test]
8808    fn sh_quote_wraps_a_space() {
8809        assert_eq!(
8810            "'/Applications/My App/spar'",
8811            sh_quote("/Applications/My App/spar")
8812        );
8813    }
8814
8815    #[test]
8816    fn finished_states_are_recognised_case_insensitively() {
8817        assert!(is_finished("MERGED"));
8818        assert!(is_finished("closed"));
8819        assert!(!is_finished("OPEN"));
8820        assert!(!is_finished(""));
8821    }
8822
8823    fn state() -> PersistedState {
8824        PersistedState {
8825            version: 1,
8826            checkpoint: 0,
8827            round: 4,
8828            next_actor: "codex".into(),
8829            status: Status::Pending,
8830            pr_head: "abc123".into(),
8831            ledger: Ledger::new(),
8832            filed: vec![],
8833            open_findings: vec![Finding {
8834                severity: Severity::Blocking,
8835                title: "Unchecked error".into(),
8836                detail: "the failure is discarded".into(),
8837                file: "src/a.rs:12".into(),
8838                ..Finding::default()
8839            }],
8840            disputes: vec![Dispute {
8841                title: "Retry limit".into(),
8842                file: "src/net.rs".into(),
8843                reasoning: "the caller already bounds it".into(),
8844            }],
8845            noted: vec![Finding {
8846                severity: Severity::NonBlocking,
8847                title: "Timeout is fixed".into(),
8848                file: "src/config.rs".into(),
8849                ..Finding::default()
8850            }],
8851        }
8852    }
8853
8854    #[test]
8855    fn a_state_comment_round_trips() {
8856        let body = format!(
8857            "{STATE_MARKER}\n{}\n-->",
8858            serde_json::to_string(&state()).unwrap()
8859        );
8860        let back = parse_state_comment(&body).unwrap();
8861        assert_eq!(4, back.round);
8862        assert_eq!("codex", back.next_actor);
8863        assert_eq!("abc123", back.pr_head);
8864        assert_eq!("Unchecked error", back.open_findings[0].title);
8865        assert_eq!("src/net.rs", back.disputes[0].file);
8866        assert_eq!("Timeout is fixed", back.noted[0].title);
8867    }
8868
8869    #[test]
8870    fn old_state_without_new_lists_still_parses() {
8871        let body = format!(
8872            "{STATE_MARKER}\n{{\"version\":1,\"round\":2,\"next_actor\":\"b\",\
8873             \"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
8874        );
8875        let back = parse_state_comment(&body).expect("old state");
8876        assert!(back.open_findings.is_empty());
8877        assert!(back.disputes.is_empty());
8878        assert!(back.noted.is_empty());
8879        assert!(back.pr_head.is_empty());
8880        assert_eq!(0, back.checkpoint);
8881    }
8882
8883    #[test]
8884    fn matching_remote_state_beats_a_newer_stale_local_checkpoint() {
8885        let mut local = state();
8886        local.pr_head = "old".into();
8887        local.round = 9;
8888        let mut remote = state();
8889        remote.pr_head = "current".into();
8890        remote.round = 4;
8891
8892        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
8893        assert_eq!("current", chosen.pr_head);
8894        assert_eq!(4, chosen.round);
8895    }
8896
8897    #[test]
8898    fn checkpoint_order_breaks_same_round_ties() {
8899        let mut local = state();
8900        local.pr_head = "current".into();
8901        local.round = 4;
8902        local.checkpoint = 8;
8903        let mut remote = local.clone();
8904        remote.checkpoint = 7;
8905        remote.open_findings.clear();
8906
8907        let chosen = choose_state_for_head(vec![local], "current").unwrap();
8908        assert_eq!(8, chosen.checkpoint);
8909
8910        let mut local = state();
8911        local.pr_head = "current".into();
8912        local.round = 4;
8913        local.checkpoint = 8;
8914        let chosen = choose_state_for_head(vec![remote, local], "current").unwrap();
8915        assert_eq!(8, chosen.checkpoint);
8916    }
8917
8918    #[test]
8919    fn legacy_same_round_tie_keeps_the_local_checkpoint() {
8920        let mut local = state();
8921        local.pr_head = "current".into();
8922        local.round = 4;
8923        local.open_findings.push(Finding {
8924            title: "local checkpoint".into(),
8925            ..Finding::default()
8926        });
8927        let mut remote = state();
8928        remote.pr_head = "current".into();
8929        remote.round = 4;
8930
8931        let chosen = choose_state_for_head(vec![local, remote], "current").unwrap();
8932        assert_eq!(
8933            "local checkpoint",
8934            chosen.open_findings.last().unwrap().title
8935        );
8936    }
8937
8938    /// It must render as nothing, so PRs are not littered with machine state.
8939    #[test]
8940    fn the_state_block_is_an_html_comment() {
8941        let body = format!(
8942            "{STATE_MARKER}\n{}\n-->",
8943            serde_json::to_string(&state()).unwrap()
8944        );
8945        assert!(body.starts_with("<!--"));
8946        assert!(body.trim_end().ends_with("-->"));
8947        assert!(!body[..body.find('{').unwrap()].contains("-->"));
8948    }
8949
8950    #[test]
8951    fn an_unrelated_json_block_is_not_state() {
8952        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
8953    }
8954
8955    /// Patching and deleting a comment is not something to do on a substring.
8956    ///
8957    /// A maintainer who notices the block and asks about it writes a comment
8958    /// containing `spar:state`, and the loose filter could not tell theirs from
8959    /// spar's. The next `persist` replaced their words with JSON and
8960    /// `spar clean --pr-state` later deleted it.
8961    #[test]
8962    fn a_persons_comment_about_the_state_block_is_not_the_state_block() {
8963        let block = format!(
8964            "{STATE_MARKER}\n{}\n-->",
8965            serde_json::to_string(&state()).unwrap()
8966        );
8967        assert!(is_state_comment(&block, "spar-bot", "spar-bot"));
8968        assert!(
8969            is_state_comment(&block, "Spar-Bot", "spar-bot"),
8970            "a login differs only in case"
8971        );
8972
8973        assert!(
8974            !is_state_comment(
8975                "why is there a `<!-- spar:state` block on this PR?",
8976                "maintainer",
8977                "spar-bot"
8978            ),
8979            "a question about the block was treated as the block"
8980        );
8981        assert!(
8982            !is_state_comment(&block, "maintainer", "spar-bot"),
8983            "somebody else's comment is not spar's to rewrite"
8984        );
8985        assert!(
8986            !is_state_comment(
8987                &format!("here is what spar writes:\n\n{block}"),
8988                "spar-bot",
8989                "spar-bot"
8990            ),
8991            "the marker has to open the body, not appear in it"
8992        );
8993        assert!(
8994            !is_state_comment(
8995                &format!("{STATE_MARKER}\n{{not json\n-->"),
8996                "spar-bot",
8997                "spar-bot"
8998            ),
8999            "a block that does not parse is not state"
9000        );
9001        assert!(
9002            !is_state_comment(&block, "spar-bot", ""),
9003            "an unknown viewer proves nothing about who wrote it"
9004        );
9005    }
9006
9007    #[test]
9008    fn a_malformed_state_comment_is_none_not_a_panic() {
9009        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
9010    }
9011
9012    #[test]
9013    fn atomic_write_leaves_no_temp_file() {
9014        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
9015        let _ = std::fs::remove_dir_all(&dir);
9016        let path = dir.join("state").join("pr-7.json");
9017        write_json_atomic(&path, &state()).unwrap();
9018        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
9019            .unwrap()
9020            .flatten()
9021            .filter_map(|e| e.file_name().to_str().map(str::to_string))
9022            .collect();
9023        assert_eq!(vec!["pr-7.json".to_string()], files);
9024        let _ = std::fs::remove_dir_all(&dir);
9025    }
9026
9027    #[test]
9028    fn atomic_write_overwrites_rather_than_accumulating() {
9029        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
9030        let _ = std::fs::remove_dir_all(&dir);
9031        let path = dir.join("pr-7.json");
9032        for round in 1..4 {
9033            let mut s = state();
9034            s.round = round;
9035            write_json_atomic(&path, &s).unwrap();
9036        }
9037        let back: PersistedState =
9038            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
9039        assert_eq!(3, back.round);
9040        let _ = std::fs::remove_dir_all(&dir);
9041    }
9042
9043    #[test]
9044    fn style_from_env_defaults_to_enforcing() {
9045        std::env::remove_var("SPAR_BAN_EM_DASH");
9046        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
9047        let style = style_from_env();
9048        assert!(style.ban_em_dash && style.ban_ai_attribution);
9049        assert!(
9050            !style.terse,
9051            "the commit filter must not truncate a commit message"
9052        );
9053    }
9054}
9055
9056#[cfg(test)]
9057mod comment_page_tests {
9058    use super::*;
9059
9060    #[test]
9061    fn a_single_merged_array_is_read() {
9062        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
9063        assert_eq!(2, pages.len());
9064        assert_eq!(Some(2), pages[1]["id"].as_i64());
9065    }
9066
9067    #[test]
9068    fn concatenated_pages_from_an_older_gh_are_read_too() {
9069        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
9070        assert_eq!(2, pages.len());
9071    }
9072
9073    /// A comment body containing a bracket pair used to split the payload into
9074    /// two invalid halves, so no state comment was found and a resume silently
9075    /// started from round one.
9076    #[test]
9077    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
9078        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
9079        let pages = parse_comment_pages(text);
9080        assert_eq!(2, pages.len(), "{pages:?}");
9081        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
9082    }
9083
9084    #[test]
9085    fn empty_output_is_no_comments_not_a_panic() {
9086        assert!(parse_comment_pages("").is_empty());
9087        assert!(parse_comment_pages("   ").is_empty());
9088        assert!(parse_comment_pages("[]").is_empty());
9089    }
9090
9091    #[test]
9092    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
9093        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
9094    }
9095
9096    #[test]
9097    fn a_write_postcheck_rejects_truncated_comment_pages() {
9098        let error = try_parse_comment_pages(r#"[{"body":"the summary"}]["#).unwrap_err();
9099        assert!(
9100            error.to_string().contains("unexpected comment pages"),
9101            "{error}"
9102        );
9103    }
9104
9105    #[test]
9106    fn a_write_postcheck_rejects_empty_or_non_array_output() {
9107        assert!(try_parse_comment_pages("").is_err());
9108        assert!(try_parse_comment_pages(r#"{"body":"the summary"}"#).is_err());
9109        assert!(try_parse_comment_pages("[]").is_ok());
9110    }
9111
9112    #[test]
9113    fn state_is_found_in_the_last_matching_comment() {
9114        let payload = |round: u32| {
9115            format!(
9116                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
9117            )
9118        };
9119        let text = serde_json::to_string(&serde_json::json!([
9120            {"id": 1, "body": payload(1)},
9121            {"id": 2, "body": "looks good to me"},
9122            {"id": 3, "body": payload(5)},
9123        ]))
9124        .unwrap();
9125        let pages = parse_comment_pages(&text);
9126        let last = pages
9127            .iter()
9128            .rev()
9129            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
9130            .unwrap();
9131        assert_eq!(5, last.round);
9132    }
9133}
9134
9135#[cfg(test)]
9136mod linked_pr_tests {
9137    use super::*;
9138
9139    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
9140    /// It carries an id and a whole repository object per linked issue, and a
9141    /// parser that chokes on those reports "no pull request", which is the one
9142    /// answer that makes spar implement over the top of somebody's work.
9143    const REAL_PAYLOAD: &str = r#"[
9144      {"number":14252,"title":"fix: reject leading-dash branch names",
9145       "url":"https://github.com/cli/cli/pull/14252",
9146       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
9147         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
9148         "url":"https://github.com/cli/cli/issues/14238"}]},
9149      {"number":14217,"title":"another change",
9150       "url":"https://github.com/cli/cli/pull/14217",
9151       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
9152         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
9153         "url":"https://github.com/cli/cli/issues/9761"}]},
9154      {"number":14200,"title":"unlinked work",
9155       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
9156    ]"#;
9157
9158    #[test]
9159    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
9160        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
9161        assert_eq!(14252, pr.number);
9162        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
9163    }
9164
9165    #[test]
9166    fn the_right_pr_is_picked_out_of_several() {
9167        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
9168    }
9169
9170    #[test]
9171    fn an_issue_nobody_is_working_on_finds_nothing() {
9172        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
9173    }
9174
9175    #[test]
9176    fn an_unlinked_pr_is_never_matched() {
9177        // 14200 closes nothing, so no issue number should ever return it.
9178        for issue in [14200, 0, 1] {
9179            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
9180                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
9181            }
9182        }
9183    }
9184
9185    #[test]
9186    fn empty_or_broken_output_is_none_rather_than_a_panic() {
9187        assert!(find_linked_pr("", 1).is_none());
9188        assert!(find_linked_pr("[]", 1).is_none());
9189        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
9190        assert!(find_linked_pr("[{\"number\":", 1).is_none());
9191    }
9192
9193    /// "gh could not answer" and "nobody is working on it" are different
9194    /// answers, and only one of them means implement from scratch.
9195    ///
9196    /// The parse used to fold both into `None`, so an authentication failure or
9197    /// a rate limit read as an empty list and spar opened a second pull request
9198    /// for an issue somebody already had work open on.
9199    #[test]
9200    fn an_unreadable_list_is_not_an_empty_list() {
9201        assert!(linked_pr_rows("gh: Not Found (HTTP 404)").is_none());
9202        assert!(linked_pr_rows("[{\"number\":").is_none());
9203        assert!(linked_pr_rows("").is_none());
9204
9205        let empty = linked_pr_rows("[]").expect("an empty list is an answer");
9206        assert!(empty.is_empty());
9207        assert!(pick_linked_pr(&empty, 14238).is_none());
9208
9209        let listed = linked_pr_rows(REAL_PAYLOAD).expect("the real payload reads");
9210        assert_eq!(3, listed.len());
9211        assert_eq!(14252, pick_linked_pr(&listed, 14238).unwrap().number);
9212    }
9213
9214    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
9215    #[test]
9216    fn pr_view_reads_the_cross_repository_flag() {
9217        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
9218                       "baseRefName":"main","state":"OPEN",
9219                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
9220        let pr: PrView = serde_json::from_str(json).unwrap();
9221        assert!(pr.is_cross_repository);
9222        assert!(pr.is_open());
9223
9224        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
9225        assert!(
9226            !serde_json::from_str::<PrView>(&same_repo)
9227                .unwrap()
9228                .is_cross_repository
9229        );
9230    }
9231}
9232
9233#[cfg(test)]
9234mod min_number_tests {
9235    /// The floor is applied before the cap, which is the order that matters.
9236    /// spar takes the *lowest* numbered open items, so a repository with a tail
9237    /// of old issues would otherwise spend its whole run in the tail: the cap
9238    /// would be filled by the oldest items and the floor would never be
9239    /// reached. Filtering first is what makes the setting do anything.
9240    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
9241        let mut numbers: Vec<i64> = open.to_vec();
9242        numbers.sort_unstable();
9243        if min_number > 0 {
9244            numbers.retain(|n| *n >= min_number);
9245        }
9246        numbers.truncate(limit);
9247        numbers
9248    }
9249
9250    #[test]
9251    fn the_floor_is_applied_before_the_cap_not_after() {
9252        let open = [12, 13, 14, 480, 481, 482];
9253        assert_eq!(vec![480, 481], pick(&open, 2, 480));
9254        // Capping first would have returned the two oldest and then filtered
9255        // them all away, leaving nothing.
9256        assert!(!pick(&open, 2, 480).is_empty());
9257    }
9258
9259    #[test]
9260    fn no_floor_keeps_the_old_behaviour() {
9261        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
9262    }
9263
9264    #[test]
9265    fn the_floor_is_inclusive() {
9266        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
9267    }
9268
9269    #[test]
9270    fn a_floor_above_everything_open_yields_nothing() {
9271        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
9272    }
9273}