Skip to main content

spar/
repo.rs

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