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