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