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