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