Skip to main content

speccoder_runtime/
worktree.rs

1//! Isolation and checkpoints.
2//!
3//! speccoder works in a git worktree on `speccoder/<feature>` under its own
4//! state directory, so the user's checkout is never touched. A checkpoint is a
5//! git tree object of the whole working tree (tracked and untracked, not
6//! ignored), written through a temporary index: HEAD, the index and refs never
7//! move. Restoring a checkpoint rewrites only the paths that differ.
8
9use std::fs;
10use std::path::{Component, Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use sha2::{Digest, Sha256};
15
16use crate::format::CommitPrecondition;
17use crate::git::{Git, GitError, nul_fields};
18
19/// Why speccoder will not work in a worktree here.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Refusal {
22    /// What is wrong, in one line.
23    pub message: String,
24    /// Offending paths (repository-relative) with a reason each.
25    pub paths: Vec<(String, String)>,
26    /// What to do.
27    pub hint: String,
28    /// The catalog code (`GIT`, `NO-REPOSITORY`, `UNCOMMITTED-INTENT`).
29    pub code: &'static str,
30}
31
32impl Refusal {
33    fn new(message: impl Into<String>, hint: impl Into<String>) -> Self {
34        Self {
35            code: "GIT",
36            message: message.into(),
37            paths: Vec::new(),
38            hint: hint.into(),
39        }
40    }
41}
42
43impl From<GitError> for Refusal {
44    fn from(e: GitError) -> Self {
45        Self::new(
46            e.0,
47            "check that git is installed and the repository is healthy",
48        )
49    }
50}
51
52/// speccoder's state directory: `$SPECCODER_STATE_DIR`, else
53/// `$XDG_STATE_HOME/speccoder`, else `~/.local/state/speccoder`.
54#[must_use]
55pub fn state_dir() -> Option<PathBuf> {
56    let var = |k| {
57        std::env::var_os(k)
58            .filter(|v| !v.is_empty())
59            .map(PathBuf::from)
60    };
61    var("SPECCODER_STATE_DIR")
62        .or_else(|| var("XDG_STATE_HOME").map(|p| p.join("speccoder")))
63        .or_else(|| var("HOME").map(|h| h.join(".local/state/speccoder")))
64}
65
66/// A repository's directory under the state dir, keyed by its canonical
67/// common git directory (shared by all of its worktrees).
68pub fn repo_state_dir(state: &Path, repo: &Git) -> Result<PathBuf, GitError> {
69    let common = repo.line(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
70    let canonical = fs::canonicalize(&common).unwrap_or_else(|_| PathBuf::from(&common));
71    let digest = Sha256::digest(canonical.to_string_lossy().as_bytes());
72    let id: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
73    Ok(state.join(id))
74}
75
76/// A project's directory under the state dir: per git repository when the
77/// project is in one (shared by its worktrees), else per canonical path.
78#[must_use]
79pub fn project_state_dir(state: &Path, project: &Path) -> PathBuf {
80    let git = Git::at(project);
81    if let Ok(Some(_)) = git.toplevel()
82        && let Ok(dir) = repo_state_dir(state, &git)
83    {
84        return dir;
85    }
86    let canonical = fs::canonicalize(project).unwrap_or_else(|_| project.to_path_buf());
87    let digest = Sha256::digest(canonical.to_string_lossy().as_bytes());
88    let id: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
89    state.join(id)
90}
91
92/// A worktree speccoder works in.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Worktree {
95    /// Absolute path of the worktree.
96    pub path: PathBuf,
97    /// `speccoder/<feature>`.
98    pub branch: String,
99    /// Commit the branch points at.
100    pub base: String,
101    /// Created now (`false`: reused).
102    pub created: bool,
103}
104
105/// A detached worktree of a checkout's `HEAD` for one check (the review's
106/// red check): never the person's checkout, never the feature's
107/// worktree. Removed when dropped — on every way out.
108#[derive(Debug)]
109pub struct Scratch {
110    repo: PathBuf,
111    /// The worktree's root.
112    pub path: PathBuf,
113}
114
115/// A scratch worktree of `checkout`'s `HEAD` under `under`.
116pub fn scratch(checkout: &Path, under: &Path) -> Result<Scratch, GitError> {
117    let git = Git::at(checkout);
118    let top = git
119        .toplevel()?
120        .ok_or_else(|| GitError("not a git repository".to_owned()))?;
121    fs::create_dir_all(under).map_err(|e| GitError(format!("{}: {e}", under.display())))?;
122    let nanos = std::time::SystemTime::now()
123        .duration_since(std::time::UNIX_EPOCH)
124        .map_or(0, |d| d.as_nanos());
125    let path = under.join(format!("scratch-{}-{nanos}", std::process::id()));
126    let path_str = path.display().to_string();
127    Git::at(&top).run(&["worktree", "add", "--quiet", "--detach", &path_str, "HEAD"])?;
128    Ok(Scratch { repo: top, path })
129}
130
131impl Drop for Scratch {
132    fn drop(&mut self) {
133        // Its records, kept beside it.
134        crate::prepare::forget(&self.path);
135        crate::include::forget(&self.path);
136        let path = self.path.display().to_string();
137        let git = Git::at(&self.repo);
138        let _ = git.run(&["worktree", "remove", "--force", &path]);
139        let _ = fs::remove_dir_all(&self.path);
140        let _ = git.run(&["worktree", "prune"]);
141    }
142}
143
144/// Creates or reuses the worktree for `feature`.
145///
146/// `repo_root` is the git working tree of the user's checkout; `committed`
147/// says which repository-relative spec paths must be committed.
148pub fn ensure_worktree(
149    repo_root: &Path,
150    state: &Path,
151    feature: &str,
152    committed: impl Into<CommitPrecondition>,
153) -> Result<Worktree, Refusal> {
154    let artifacts = &committed.into();
155    let repo = Git::at(repo_root);
156    let Some(_head) = repo.head()? else {
157        return Err(Refusal {
158            code: "NO-REPOSITORY",
159            ..Refusal::new(
160                "the repository has no commits yet; a worktree needs one",
161                artifacts.no_commits_hint,
162            )
163        });
164    };
165    require_committed(&repo, artifacts)?;
166
167    let branch = format!("speccoder/{feature}");
168    let path = repo_state_dir(state, &repo)?
169        .join("worktrees")
170        .join(feature);
171
172    // Forget worktrees whose directories were removed by hand.
173    repo.run(&["worktree", "prune"])?;
174    if let Some(existing) = worktree_for(&repo, &branch)? {
175        bring_spec_changes(&repo, &existing, &branch, artifacts)?;
176        let base = Git::at(&existing).line(&["rev-parse", "HEAD"])?;
177        return Ok(Worktree {
178            path: existing,
179            branch,
180            base,
181            created: false,
182        });
183    }
184    if path.exists() {
185        return Err(Refusal::new(
186            format!(
187                "{} exists but is not a worktree of this repository",
188                path.display()
189            ),
190            "move it away; speccoder does not delete directories it did not create",
191        ));
192    }
193    if let Some(parent) = path.parent() {
194        fs::create_dir_all(parent).map_err(|e| {
195            Refusal::new(
196                format!("{}: {e}", parent.display()),
197                "check permissions of the state directory",
198            )
199        })?;
200    }
201    let path_str = path.to_string_lossy().into_owned();
202    let branch_ref = format!("refs/heads/{branch}");
203    if repo.succeeds(&["rev-parse", "--verify", "-q", &branch_ref])? {
204        // The branch survived its worktree: re-attach, keeping its history,
205        // and bring in what the checkout's spec gained since (a task a
206        // quick change recorded meanwhile), as for a worktree that stayed.
207        repo.run(&["worktree", "add", "--quiet", &path_str, &branch])?;
208        bring_spec_changes(
209            &repo,
210            &fs::canonicalize(&path).unwrap_or(path.clone()),
211            &branch,
212            artifacts,
213        )?;
214    } else {
215        repo.run(&[
216            "worktree", "add", "--quiet", "-b", &branch, &path_str, "HEAD",
217        ])?;
218    }
219    let path = fs::canonicalize(&path).unwrap_or(path);
220    let base = Git::at(&path).line(&["rev-parse", "HEAD"])?;
221    Ok(Worktree {
222        path,
223        branch,
224        base,
225        created: true,
226    })
227}
228
229/// When the person's checkout changed the spec artifacts after the
230/// speccoder branch forked (a fixed example, a clarified requirement), the
231/// worktree would work from the old spec: merge their `HEAD` in first. A
232/// conflict is theirs to resolve; nothing is half-merged.
233fn bring_spec_changes(
234    repo: &Git,
235    worktree: &Path,
236    branch: &str,
237    artifacts: &CommitPrecondition,
238) -> Result<(), Refusal> {
239    let head = repo.line(&["rev-parse", "HEAD"])?;
240    let base = repo.line(&["merge-base", branch, &head])?;
241    // What decides a run moves the branch too: the spec, and the setup the
242    // checks run with (the configuration, the nextest profile, what is
243    // ignored, the lockfiles).
244    let mut paths = artifacts.paths();
245    paths.extend(SETUP.iter().map(|p| (*p).to_owned()));
246    let mut diff = vec!["diff", "--quiet", &base, &head, "--"];
247    diff.extend(paths.iter().map(String::as_str));
248    let moved = !repo.succeeds(&diff)?;
249    if !moved {
250        return Ok(());
251    }
252    let wt = Git::at(worktree);
253    // A lockfile the checks wrote here, untracked, is their output: the one
254    // the checkout committed takes its place.
255    for lock in LOCKFILES {
256        if worktree.join(lock).is_file()
257            && wt
258                .line(&["ls-files", "--error-unmatch", "--", lock])
259                .is_err()
260            && repo.succeeds(&["cat-file", "-e", &format!("{head}:{lock}")])?
261        {
262            let _ = fs::remove_file(worktree.join(lock));
263        }
264    }
265    let merged = wt.run(&[
266        "-c",
267        "user.name=speccoder",
268        "-c",
269        "user.email=speccoder@users.noreply.invalid",
270        "-c",
271        "commit.gpgsign=false",
272        "merge",
273        "-q",
274        "--no-edit",
275        "-m",
276        "speccoder: bring in the spec and setup changes from the checkout",
277        &head,
278    ]);
279    // Only task lists in conflict, apart only by ticks (a task recorded
280    // beside one this branch ticked): the checkout's words, the branch's
281    // ticks kept.
282    let merged = merged.or_else(|e| resolve_task_lists(worktree).map_err(|()| e));
283    if let Err(e) = merged {
284        let _ = wt.run(&["merge", "--abort"]);
285        return Err(Refusal::new(
286            format!(
287                "the spec changed in your checkout and could not be merged into {branch}: {}",
288                e.0.lines().next().unwrap_or_default()
289            ),
290            format!(
291                "merge it by hand in {} (git merge {head}), then run again",
292                worktree.display()
293            ),
294        ));
295    }
296    Ok(())
297}
298
299/// Resolves a merge whose conflicted files the branch changed only by
300/// ticking boxes — the checkout added or reworded tasks meanwhile: each
301/// takes the checkout's version with the branch's ticks kept where the
302/// words are the same, so nothing the branch did is lost. Any other change
303/// on the branch (a deletion, an edit, a file without a merge base) leaves
304/// the conflict to the person.
305fn resolve_task_lists(worktree: &Path) -> Result<Vec<u8>, ()> {
306    let wt = Git::at(worktree);
307    let conflicted = wt
308        .line(&["diff", "--name-only", "--diff-filter=U"])
309        .map_err(|_| ())?;
310    let files: Vec<&str> = conflicted.lines().filter(|l| !l.is_empty()).collect();
311    if files.is_empty() {
312        return Err(());
313    }
314    for f in files {
315        let side = |stage: u8| {
316            wt.run(&["show", &format!(":{stage}:{f}")])
317                .map(|b| String::from_utf8_lossy(&b).into_owned())
318                .map_err(|_| ())
319        };
320        let (base, ours, theirs) = (side(1)?, side(2)?, side(3)?);
321        if !only_ticked(&base, &ours) {
322            return Err(());
323        }
324        let text = keep_ticks(&ours, &theirs);
325        fs::write(worktree.join(f), text).map_err(|_| ())?;
326        wt.run(&["add", "--", f]).map_err(|_| ())?;
327    }
328    wt.run(&[
329        "-c",
330        "user.name=speccoder",
331        "-c",
332        "user.email=speccoder@users.noreply.invalid",
333        "-c",
334        "commit.gpgsign=false",
335        "commit",
336        "-q",
337        "--no-edit",
338    ])
339    .map_err(|_| ())
340}
341
342/// Whether `ours` is `base` with boxes ticked and nothing else changed.
343#[must_use]
344pub fn only_ticked(base: &str, ours: &str) -> bool {
345    let untick = |l: &str| {
346        l.replacen("- [x] ", "- [ ] ", 1)
347            .replacen("- [X] ", "- [ ] ", 1)
348    };
349    base != ours && base.lines().map(untick).eq(ours.lines().map(untick))
350}
351
352/// `theirs` with every checkbox `ours` ticked ticked too, where the line's
353/// words are the same.
354#[must_use]
355pub fn keep_ticks(ours: &str, theirs: &str) -> String {
356    let words = |l: &str, ticked: bool| -> Option<String> {
357        let t = l.trim_start();
358        let rest = if ticked {
359            t.strip_prefix("- [x] ")
360                .or_else(|| t.strip_prefix("- [X] "))
361        } else {
362            t.strip_prefix("- [ ] ")
363        }?;
364        Some(rest.trim_end().to_owned())
365    };
366    let ticked: std::collections::BTreeSet<String> =
367        ours.lines().filter_map(|l| words(l, true)).collect();
368    let mut out: Vec<String> = theirs
369        .lines()
370        .map(|l| match words(l, false) {
371            Some(w) if ticked.contains(&w) => l.replacen("- [ ] ", "- [x] ", 1),
372            _ => l.to_owned(),
373        })
374        .collect();
375    if theirs.ends_with('\n') {
376        out.push(String::new());
377    }
378    out.join("\n")
379}
380
381/// The branch of a lane: one task of `feature` run in parallel.
382#[must_use]
383pub fn lane_branch(feature: &str, lane: &str) -> String {
384    format!("speccoder-lane/{feature}/{lane}")
385}
386
387/// A fresh lane worktree for `lane` of `feature`, started at `start` (the
388/// feature branch's tip). A lane left behind is replaced: lanes are
389/// speccoder's scratch space; their work reaches the feature branch only
390/// by merging.
391pub fn ensure_lane(
392    repo_root: &Path,
393    state: &Path,
394    feature: &str,
395    lane: &str,
396    start: &str,
397) -> Result<Worktree, Refusal> {
398    let repo = Git::at(repo_root);
399    let branch = lane_branch(feature, lane);
400    let path = repo_state_dir(state, &repo)?
401        .join("worktrees")
402        .join(format!("{feature}@{lane}"));
403    remove_lane(repo_root, feature, lane)?;
404    if path.exists() {
405        return Err(Refusal::new(
406            format!(
407                "{} exists but is not a worktree of this repository",
408                path.display()
409            ),
410            "move it away; speccoder does not delete directories it did not create",
411        ));
412    }
413    if let Some(parent) = path.parent() {
414        let _ = fs::create_dir_all(parent);
415    }
416    let path_str = path.to_string_lossy().into_owned();
417    repo.run(&[
418        "worktree", "add", "--quiet", "-B", &branch, &path_str, start,
419    ])?;
420    let path = fs::canonicalize(&path).unwrap_or(path);
421    Ok(Worktree {
422        path,
423        branch,
424        base: start.to_owned(),
425        created: true,
426    })
427}
428
429/// Removes `lane`'s worktree and branch, if there are any.
430pub fn remove_lane(repo_root: &Path, feature: &str, lane: &str) -> Result<(), GitError> {
431    let repo = Git::at(repo_root);
432    let branch = lane_branch(feature, lane);
433    repo.run(&["worktree", "prune"])?;
434    if let Some(existing) = worktree_for(&repo, &branch)? {
435        // Its records, kept beside it.
436        crate::prepare::forget(&existing);
437        crate::include::forget(&existing);
438        repo.run(&["worktree", "remove", "--force", &existing.to_string_lossy()])?;
439    }
440    if repo.succeeds(&[
441        "rev-parse",
442        "--verify",
443        "-q",
444        &format!("refs/heads/{branch}"),
445    ])? {
446        repo.run(&["branch", "-q", "-D", &branch])?;
447    }
448    Ok(())
449}
450
451/// The worktree checked out on `branch`, if any.
452pub fn worktree_for(repo: &Git, branch: &str) -> Result<Option<PathBuf>, GitError> {
453    let fields = nul_fields(&repo.run(&["worktree", "list", "--porcelain", "-z"])?);
454    let wanted = format!("branch refs/heads/{branch}");
455    let mut current: Option<PathBuf> = None;
456    for f in fields {
457        if let Some(p) = f.strip_prefix("worktree ") {
458            current = Some(PathBuf::from(p));
459        } else if f == wanted {
460            return Ok(current);
461        }
462    }
463    Ok(None)
464}
465
466/// Spec Kit paths a worktree must contain.
467#[derive(Debug, Clone, PartialEq, Eq)]
468pub struct Artifacts {
469    /// Repository-relative `.specify` directory.
470    pub specify_dir: String,
471    /// Repository-relative feature directory, e.g. `specs/001-auth`.
472    pub feature_dir: String,
473}
474
475impl From<&Artifacts> for CommitPrecondition {
476    fn from(a: &Artifacts) -> Self {
477        crate::format::speckit::precondition(&a.specify_dir, &a.feature_dir)
478    }
479}
480
481/// Refuses unless the spec artifacts are committed as they are on disk:
482/// a worktree contains only commits, so anything else would be an agent
483/// working from a different spec than the user's.
484fn require_committed(repo: &Git, a: &CommitPrecondition) -> Result<(), Refusal> {
485    let mut paths: Vec<(String, String)> = Vec::new();
486
487    for dir in &a.strict {
488        let out = repo.run(&[
489            "status",
490            "--porcelain=v1",
491            "-z",
492            "--untracked-files=all",
493            "--ignored=traditional",
494            "--",
495            dir,
496        ])?;
497        paths.extend(status_entries(&out));
498    }
499    for dir in &a.tracked {
500        let out = repo.run(&[
501            "status",
502            "--porcelain=v1",
503            "-z",
504            "--untracked-files=all",
505            "--",
506            dir,
507        ])?;
508        paths.extend(
509            status_entries(&out)
510                .into_iter()
511                .filter(|(p, _)| !(a.runtime)(p)),
512        );
513    }
514
515    // And the essentials must be tracked at all (a fully ignored directory
516    // has no status entries of its own here).
517    for p in &a.essentials {
518        let tracked = !repo.run(&["ls-files", "--", p])?.is_empty();
519        if !tracked && repo.dir().join(p).exists() && !paths.iter().any(|(q, _)| q == p) {
520            let reason = if repo.succeeds(&["check-ignore", "-q", "--", p])? {
521                "ignored"
522            } else {
523                "untracked"
524            };
525            paths.push((p.clone(), reason.to_owned()));
526        }
527    }
528
529    if paths.is_empty() {
530        return Ok(());
531    }
532    paths.sort();
533    paths.dedup();
534    Err(Refusal {
535        code: "UNCOMMITTED-INTENT",
536        message: a.message.to_owned(),
537        paths,
538        hint: a.hint.to_owned(),
539    })
540}
541
542/// `git status --porcelain=v1 -z` entries as (path, reason).
543fn status_entries(out: &[u8]) -> Vec<(String, String)> {
544    let mut entries = Vec::new();
545    let mut fields = nul_fields(out).into_iter();
546    while let Some(f) = fields.next() {
547        if f.len() < 4 {
548            continue;
549        }
550        let (code, path) = f.split_at(3);
551        let code = code.trim_end();
552        let reason = match code {
553            "??" => "untracked",
554            "!!" => "ignored",
555            _ => "modified",
556        };
557        // Renames carry the original path as a second field.
558        if code.starts_with('R') || code.starts_with('C') {
559            fields.next();
560        }
561        entries.push((path.to_owned(), reason.to_owned()));
562    }
563    entries
564}
565
566/// The build output a check's toolchain writes into the tree it runs in.
567#[must_use]
568pub fn build_output(argv: &[String]) -> &'static [&'static str] {
569    let program = argv
570        .first()
571        .map(|a| a.rsplit('/').next().unwrap_or(a))
572        .unwrap_or_default();
573    match program {
574        "cargo" => &["target/"],
575        "npm" | "npx" | "pnpm" | "yarn" | "bun" => &["node_modules/"],
576        "pytest" | "python" | "python3" | "uv" => &[".pytest_cache/", "__pycache__/"],
577        _ => &[],
578    }
579}
580
581/// What the checks run with besides the spec: a change in the checkout
582/// reaches the feature's branch before a run.
583pub const SETUP: &[&str] = &[
584    "speccoder.toml",
585    ".config/nextest.toml",
586    ".gitignore",
587    "Cargo.lock",
588    "package-lock.json",
589    "pnpm-lock.yaml",
590    "yarn.lock",
591    "uv.lock",
592    "poetry.lock",
593    "go.sum",
594];
595
596/// Lockfiles a toolchain writes when the repository has none committed.
597pub const LOCKFILES: &[&str] = &[
598    "Cargo.lock",
599    "package-lock.json",
600    "pnpm-lock.yaml",
601    "yarn.lock",
602    "uv.lock",
603    "poetry.lock",
604    "go.sum",
605];
606
607/// What checks leave behind in `dir` that git does not know: build output
608/// `.gitignore` does not list (`target/`, `node_modules/`, caches), and
609/// lockfiles never committed. No one's edits — the setup's gaps.
610#[derive(Debug, Default, PartialEq, Eq)]
611pub struct Leftovers {
612    /// Directories to ignore.
613    pub ignore: Vec<String>,
614    /// Lockfiles to commit.
615    pub commit: Vec<String>,
616}
617
618impl Leftovers {
619    /// Nothing left behind.
620    #[must_use]
621    pub fn is_empty(&self) -> bool {
622        self.ignore.is_empty() && self.commit.is_empty()
623    }
624
625    /// The fix, as one command line run in `checkout`, committing `also`
626    /// with it; a lockfile only the worktree's checks wrote is copied from
627    /// `worktree`.
628    #[must_use]
629    pub fn fix(&self, checkout: &Path, worktree: &Path, also: &[String]) -> String {
630        let mut steps = Vec::new();
631        for f in &self.commit {
632            if !checkout.join(f).exists() {
633                steps.push(format!("cp '{}' .", worktree.join(f).display()));
634            }
635        }
636        if !self.ignore.is_empty() {
637            steps.push(format!(
638                "printf '{}\\n' >> .gitignore",
639                self.ignore.join("\\n")
640            ));
641        }
642        let mut add: Vec<&str> = also
643            .iter()
644            .chain(&self.commit)
645            .map(String::as_str)
646            .collect();
647        if !self.ignore.is_empty() {
648            add.push(".gitignore");
649        }
650        steps.push(format!(
651            "git add {} && git commit -m \"speccoder: setup\"",
652            add.join(" ")
653        ));
654        steps.join(" && ")
655    }
656}
657
658/// [`Leftovers`] in `dir`.
659#[must_use]
660pub fn leftovers(dir: &Path) -> Leftovers {
661    const BUILD: [&str; 4] = ["target", "node_modules", ".pytest_cache", "__pycache__"];
662    let Ok(out) = Git::at(dir).run(&["status", "--porcelain=v1", "--untracked-files=normal"])
663    else {
664        return Leftovers::default();
665    };
666    let mut found = Leftovers::default();
667    for p in String::from_utf8_lossy(&out)
668        .lines()
669        .filter_map(|l| l.strip_prefix("?? "))
670    {
671        let p = p.trim_end_matches('/');
672        let first = p.split('/').next().unwrap_or(p);
673        if BUILD.contains(&first) {
674            found.ignore.push(format!("{first}/"));
675        } else if LOCKFILES.contains(&p) {
676            found.commit.push(p.to_owned());
677        }
678    }
679    found.ignore.sort();
680    found.ignore.dedup();
681    found
682}
683
684/// Refuses when the working tree has any uncommitted change (for in-place mode).
685pub fn require_clean(dir: &Path) -> Result<(), Refusal> {
686    let out = Git::at(dir).run(&["status", "--porcelain=v1", "-z", "--untracked-files=all"])?;
687    let paths = status_entries(&out);
688    if paths.is_empty() {
689        Ok(())
690    } else {
691        Err(Refusal {
692            code: "DIRTY-CHECKOUT",
693            message: "the working tree has uncommitted changes".to_owned(),
694            paths,
695            hint: "commit or stash them first".to_owned(),
696        })
697    }
698}
699
700// ── Checkpoints ─────────────────────────────────────────────────────────────
701
702/// A snapshot of a working tree: a git tree object id.
703#[derive(Debug, Clone, PartialEq, Eq)]
704pub struct Checkpoint(pub String);
705
706/// What happened to a path between two checkpoints.
707#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
708pub enum ChangeKind {
709    /// Present only in the later checkpoint.
710    Added,
711    /// Content, mode or type differs.
712    Modified,
713    /// Present only in the earlier checkpoint.
714    Deleted,
715}
716
717/// One changed path (repository-relative).
718#[derive(Debug, Clone, PartialEq, Eq)]
719pub struct Change {
720    /// The path.
721    pub path: String,
722    /// What happened.
723    pub kind: ChangeKind,
724}
725
726/// A temporary index file that is removed when dropped.
727struct TempIndex(PathBuf);
728
729impl TempIndex {
730    fn new(tmp: &Path) -> Result<Self, GitError> {
731        static COUNTER: AtomicU64 = AtomicU64::new(0);
732        fs::create_dir_all(tmp).map_err(|e| GitError(format!("{}: {e}", tmp.display())))?;
733        let nanos = SystemTime::now()
734            .duration_since(UNIX_EPOCH)
735            .map(|d| d.as_nanos())
736            .unwrap_or(0);
737        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
738        Ok(Self(
739            tmp.join(format!("index-{}-{nanos}-{n}", std::process::id())),
740        ))
741    }
742}
743
744impl Drop for TempIndex {
745    fn drop(&mut self) {
746        // Our own temporary file; nothing else is ever removed here.
747        let _ = fs::remove_file(&self.0);
748    }
749}
750
751/// Snapshots the working tree of `dir`. `tmp` holds the temporary
752/// index (inside speccoder's state directory).
753pub fn checkpoint(dir: &Path, tmp: &Path) -> Result<Checkpoint, GitError> {
754    let git = Git::at(dir);
755    let index = TempIndex::new(tmp)?;
756    // Start from the real index so unchanged files are not rehashed.
757    let real = git.git_path("index")?;
758    if real.exists() {
759        fs::copy(&real, &index.0).map_err(|e| GitError(format!("copy index: {e}")))?;
760    } else if git.head()?.is_some() {
761        git.run_with_index(&index.0, &["read-tree", "HEAD"], None)?;
762    } else {
763        git.run_with_index(&index.0, &["read-tree", "--empty"], None)?;
764    }
765    // Never a file copied from the checkout ([worktree] include): a
766    // checkpoint can become a commit (a saved attempt, `keep`).
767    let excluded = git
768        .toplevel()?
769        .map(|top| crate::include::pathspecs(&top))
770        .unwrap_or_default();
771    let mut add = vec!["add", "-A", "--", ":/"];
772    add.extend(excluded.iter().map(String::as_str));
773    git.run_with_index(&index.0, &add, None)?;
774    let tree = git.run_with_index(&index.0, &["write-tree"], None)?;
775    Ok(Checkpoint(String::from_utf8_lossy(&tree).trim().to_owned()))
776}
777
778/// Paths that differ from `from` to `to`.
779pub fn diff(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<Vec<Change>, GitError> {
780    let out = Git::at(dir).run(&[
781        "diff-tree",
782        "-r",
783        "-z",
784        "--no-renames",
785        "--name-status",
786        &from.0,
787        &to.0,
788    ])?;
789    let fields = nul_fields(&out);
790    let mut changes = Vec::new();
791    for pair in fields.chunks(2) {
792        let [status, path] = pair else { continue };
793        let kind = match status.chars().next() {
794            Some('A') => ChangeKind::Added,
795            Some('D') => ChangeKind::Deleted,
796            _ => ChangeKind::Modified,
797        };
798        changes.push(Change {
799            path: path.clone(),
800            kind,
801        });
802    }
803    Ok(changes)
804}
805
806/// Every file path in a checkpoint.
807pub fn files(dir: &Path, checkpoint: &Checkpoint) -> Result<Vec<String>, GitError> {
808    let out = Git::at(dir).run(&["ls-tree", "-r", "--name-only", "-z", &checkpoint.0])?;
809    Ok(nul_fields(&out))
810}
811
812/// The textual diff between two checkpoints, without context lines.
813pub fn patch(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<String, GitError> {
814    let out = Git::at(dir).run(&["diff", "--no-color", "--no-renames", "-U0", &from.0, &to.0])?;
815    Ok(String::from_utf8_lossy(&out).into_owned())
816}
817
818/// Makes the working tree of `dir` equal to `target`: differing
819/// paths are rewritten from the checkpoint, paths the checkpoint lacks are
820/// removed. HEAD, the index and refs are not touched. Returns what changed.
821pub fn restore(dir: &Path, tmp: &Path, target: &Checkpoint) -> Result<Vec<Change>, GitError> {
822    let git = Git::at(dir);
823    let root = git
824        .toplevel()?
825        .ok_or_else(|| GitError(format!("{} is not in a git repository", dir.display())))?;
826    let current = checkpoint(&root, tmp)?;
827    let changes = diff(&root, target, &current)?;
828    if changes.is_empty() {
829        return Ok(changes);
830    }
831
832    // Changed relative to target: `Added` = only in current (remove);
833    // `Deleted`/`Modified` = take the target's version.
834    let mut write: Vec<u8> = Vec::new();
835    let mut remove: Vec<&str> = Vec::new();
836    for c in &changes {
837        if !is_plain_relative(&c.path) {
838            return Err(GitError(format!(
839                "refusing to restore unsafe path {}",
840                c.path
841            )));
842        }
843        match c.kind {
844            ChangeKind::Added => remove.push(&c.path),
845            ChangeKind::Deleted | ChangeKind::Modified => {
846                write.extend_from_slice(c.path.as_bytes());
847                write.push(0);
848            }
849        }
850    }
851    let rgit = Git::at(&root);
852    for path in &remove {
853        let full = root.join(path);
854        // A path being replaced by a directory/file of another type is handled
855        // by checkout-index below; remove the current entry without following links.
856        if fs::symlink_metadata(&full).is_ok() {
857            fs::remove_file(&full).map_err(|e| GitError(format!("remove {path}: {e}")))?;
858            prune_empty_parents(&root, &full);
859        }
860    }
861    if !write.is_empty() {
862        let index = TempIndex::new(tmp)?;
863        rgit.run_with_index(&index.0, &["read-tree", &target.0], None)?;
864        rgit.run_with_index(
865            &index.0,
866            &["checkout-index", "-f", "-z", "--stdin"],
867            Some(&write),
868        )?;
869    }
870    Ok(changes)
871}
872
873fn is_plain_relative(path: &str) -> bool {
874    let p = Path::new(path);
875    !path.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))
876}
877
878/// Removes directories left empty by a removal, up to (not including) `root`.
879fn prune_empty_parents(root: &Path, removed: &Path) {
880    let mut dir = removed.parent();
881    while let Some(d) = dir {
882        if d == root || !d.starts_with(root) || fs::remove_dir(d).is_err() {
883            break;
884        }
885        dir = d.parent();
886    }
887}