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