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