Skip to main content

spar/
repo.rs

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