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