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        repo.run(&["worktree", "add", "--quiet", &path_str, &branch])?;
206    } else {
207        repo.run(&[
208            "worktree", "add", "--quiet", "-b", &branch, &path_str, "HEAD",
209        ])?;
210    }
211    let path = fs::canonicalize(&path).unwrap_or(path);
212    let base = Git::at(&path).line(&["rev-parse", "HEAD"])?;
213    Ok(Worktree {
214        path,
215        branch,
216        base,
217        created: true,
218    })
219}
220
221/// When the person's checkout changed the spec artifacts after the
222/// speccoder branch forked (a fixed example, a clarified requirement), the
223/// worktree would work from the old spec: merge their `HEAD` in first. A
224/// conflict is theirs to resolve; nothing is half-merged.
225fn bring_spec_changes(
226    repo: &Git,
227    worktree: &Path,
228    branch: &str,
229    artifacts: &CommitPrecondition,
230) -> Result<(), Refusal> {
231    let head = repo.line(&["rev-parse", "HEAD"])?;
232    let base = repo.line(&["merge-base", branch, &head])?;
233    // What decides a run moves the branch too: the spec, and the setup the
234    // checks run with (the configuration, the nextest profile, what is
235    // ignored, the lockfiles).
236    let mut paths = artifacts.paths();
237    paths.extend(SETUP.iter().map(|p| (*p).to_owned()));
238    let mut diff = vec!["diff", "--quiet", &base, &head, "--"];
239    diff.extend(paths.iter().map(String::as_str));
240    let moved = !repo.succeeds(&diff)?;
241    if !moved {
242        return Ok(());
243    }
244    let wt = Git::at(worktree);
245    // A lockfile the checks wrote here, untracked, is their output: the one
246    // the checkout committed takes its place.
247    for lock in LOCKFILES {
248        if worktree.join(lock).is_file()
249            && wt
250                .line(&["ls-files", "--error-unmatch", "--", lock])
251                .is_err()
252            && repo.succeeds(&["cat-file", "-e", &format!("{head}:{lock}")])?
253        {
254            let _ = fs::remove_file(worktree.join(lock));
255        }
256    }
257    let merged = wt.run(&[
258        "-c",
259        "user.name=speccoder",
260        "-c",
261        "user.email=speccoder@users.noreply.invalid",
262        "-c",
263        "commit.gpgsign=false",
264        "merge",
265        "-q",
266        "--no-edit",
267        "-m",
268        "speccoder: bring in the spec and setup changes from the checkout",
269        &head,
270    ]);
271    if let Err(e) = merged {
272        let _ = wt.run(&["merge", "--abort"]);
273        return Err(Refusal::new(
274            format!(
275                "the spec changed in your checkout and could not be merged into {branch}: {}",
276                e.0.lines().next().unwrap_or_default()
277            ),
278            format!(
279                "merge it by hand in {} (git merge {head}), then run again",
280                worktree.display()
281            ),
282        ));
283    }
284    Ok(())
285}
286
287/// The branch of a lane: one task of `feature` run in parallel.
288#[must_use]
289pub fn lane_branch(feature: &str, lane: &str) -> String {
290    format!("speccoder-lane/{feature}/{lane}")
291}
292
293/// A fresh lane worktree for `lane` of `feature`, started at `start` (the
294/// feature branch's tip). A lane left behind is replaced: lanes are
295/// speccoder's scratch space; their work reaches the feature branch only
296/// by merging.
297pub fn ensure_lane(
298    repo_root: &Path,
299    state: &Path,
300    feature: &str,
301    lane: &str,
302    start: &str,
303) -> Result<Worktree, Refusal> {
304    let repo = Git::at(repo_root);
305    let branch = lane_branch(feature, lane);
306    let path = repo_state_dir(state, &repo)?
307        .join("worktrees")
308        .join(format!("{feature}@{lane}"));
309    remove_lane(repo_root, feature, lane)?;
310    if path.exists() {
311        return Err(Refusal::new(
312            format!(
313                "{} exists but is not a worktree of this repository",
314                path.display()
315            ),
316            "move it away; speccoder does not delete directories it did not create",
317        ));
318    }
319    if let Some(parent) = path.parent() {
320        let _ = fs::create_dir_all(parent);
321    }
322    let path_str = path.to_string_lossy().into_owned();
323    repo.run(&[
324        "worktree", "add", "--quiet", "-B", &branch, &path_str, start,
325    ])?;
326    let path = fs::canonicalize(&path).unwrap_or(path);
327    Ok(Worktree {
328        path,
329        branch,
330        base: start.to_owned(),
331        created: true,
332    })
333}
334
335/// Removes `lane`'s worktree and branch, if there are any.
336pub fn remove_lane(repo_root: &Path, feature: &str, lane: &str) -> Result<(), GitError> {
337    let repo = Git::at(repo_root);
338    let branch = lane_branch(feature, lane);
339    repo.run(&["worktree", "prune"])?;
340    if let Some(existing) = worktree_for(&repo, &branch)? {
341        // Its records, kept beside it.
342        crate::prepare::forget(&existing);
343        crate::include::forget(&existing);
344        repo.run(&["worktree", "remove", "--force", &existing.to_string_lossy()])?;
345    }
346    if repo.succeeds(&[
347        "rev-parse",
348        "--verify",
349        "-q",
350        &format!("refs/heads/{branch}"),
351    ])? {
352        repo.run(&["branch", "-q", "-D", &branch])?;
353    }
354    Ok(())
355}
356
357/// The worktree checked out on `branch`, if any.
358pub fn worktree_for(repo: &Git, branch: &str) -> Result<Option<PathBuf>, GitError> {
359    let fields = nul_fields(&repo.run(&["worktree", "list", "--porcelain", "-z"])?);
360    let wanted = format!("branch refs/heads/{branch}");
361    let mut current: Option<PathBuf> = None;
362    for f in fields {
363        if let Some(p) = f.strip_prefix("worktree ") {
364            current = Some(PathBuf::from(p));
365        } else if f == wanted {
366            return Ok(current);
367        }
368    }
369    Ok(None)
370}
371
372/// Spec Kit paths a worktree must contain.
373#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct Artifacts {
375    /// Repository-relative `.specify` directory.
376    pub specify_dir: String,
377    /// Repository-relative feature directory, e.g. `specs/001-auth`.
378    pub feature_dir: String,
379}
380
381impl From<&Artifacts> for CommitPrecondition {
382    fn from(a: &Artifacts) -> Self {
383        crate::format::speckit::precondition(&a.specify_dir, &a.feature_dir)
384    }
385}
386
387/// Refuses unless the spec artifacts are committed as they are on disk:
388/// a worktree contains only commits, so anything else would be an agent
389/// working from a different spec than the user's.
390fn require_committed(repo: &Git, a: &CommitPrecondition) -> Result<(), Refusal> {
391    let mut paths: Vec<(String, String)> = Vec::new();
392
393    for dir in &a.strict {
394        let out = repo.run(&[
395            "status",
396            "--porcelain=v1",
397            "-z",
398            "--untracked-files=all",
399            "--ignored=traditional",
400            "--",
401            dir,
402        ])?;
403        paths.extend(status_entries(&out));
404    }
405    for dir in &a.tracked {
406        let out = repo.run(&[
407            "status",
408            "--porcelain=v1",
409            "-z",
410            "--untracked-files=all",
411            "--",
412            dir,
413        ])?;
414        paths.extend(
415            status_entries(&out)
416                .into_iter()
417                .filter(|(p, _)| !(a.runtime)(p)),
418        );
419    }
420
421    // And the essentials must be tracked at all (a fully ignored directory
422    // has no status entries of its own here).
423    for p in &a.essentials {
424        let tracked = !repo.run(&["ls-files", "--", p])?.is_empty();
425        if !tracked && repo.dir().join(p).exists() && !paths.iter().any(|(q, _)| q == p) {
426            let reason = if repo.succeeds(&["check-ignore", "-q", "--", p])? {
427                "ignored"
428            } else {
429                "untracked"
430            };
431            paths.push((p.clone(), reason.to_owned()));
432        }
433    }
434
435    if paths.is_empty() {
436        return Ok(());
437    }
438    paths.sort();
439    paths.dedup();
440    Err(Refusal {
441        code: "UNCOMMITTED-INTENT",
442        message: a.message.to_owned(),
443        paths,
444        hint: a.hint.to_owned(),
445    })
446}
447
448/// `git status --porcelain=v1 -z` entries as (path, reason).
449fn status_entries(out: &[u8]) -> Vec<(String, String)> {
450    let mut entries = Vec::new();
451    let mut fields = nul_fields(out).into_iter();
452    while let Some(f) = fields.next() {
453        if f.len() < 4 {
454            continue;
455        }
456        let (code, path) = f.split_at(3);
457        let code = code.trim_end();
458        let reason = match code {
459            "??" => "untracked",
460            "!!" => "ignored",
461            _ => "modified",
462        };
463        // Renames carry the original path as a second field.
464        if code.starts_with('R') || code.starts_with('C') {
465            fields.next();
466        }
467        entries.push((path.to_owned(), reason.to_owned()));
468    }
469    entries
470}
471
472/// The build output a check's toolchain writes into the tree it runs in.
473#[must_use]
474pub fn build_output(argv: &[String]) -> &'static [&'static str] {
475    let program = argv
476        .first()
477        .map(|a| a.rsplit('/').next().unwrap_or(a))
478        .unwrap_or_default();
479    match program {
480        "cargo" => &["target/"],
481        "npm" | "npx" | "pnpm" | "yarn" | "bun" => &["node_modules/"],
482        "pytest" | "python" | "python3" | "uv" => &[".pytest_cache/", "__pycache__/"],
483        _ => &[],
484    }
485}
486
487/// What the checks run with besides the spec: a change in the checkout
488/// reaches the feature's branch before a run.
489pub const SETUP: &[&str] = &[
490    "speccoder.toml",
491    ".config/nextest.toml",
492    ".gitignore",
493    "Cargo.lock",
494    "package-lock.json",
495    "pnpm-lock.yaml",
496    "yarn.lock",
497    "uv.lock",
498    "poetry.lock",
499    "go.sum",
500];
501
502/// Lockfiles a toolchain writes when the repository has none committed.
503pub const LOCKFILES: &[&str] = &[
504    "Cargo.lock",
505    "package-lock.json",
506    "pnpm-lock.yaml",
507    "yarn.lock",
508    "uv.lock",
509    "poetry.lock",
510    "go.sum",
511];
512
513/// What checks leave behind in `dir` that git does not know: build output
514/// `.gitignore` does not list (`target/`, `node_modules/`, caches), and
515/// lockfiles never committed. No one's edits — the setup's gaps.
516#[derive(Debug, Default, PartialEq, Eq)]
517pub struct Leftovers {
518    /// Directories to ignore.
519    pub ignore: Vec<String>,
520    /// Lockfiles to commit.
521    pub commit: Vec<String>,
522}
523
524impl Leftovers {
525    /// Nothing left behind.
526    #[must_use]
527    pub fn is_empty(&self) -> bool {
528        self.ignore.is_empty() && self.commit.is_empty()
529    }
530
531    /// The fix, as one command line run in `checkout`, committing `also`
532    /// with it; a lockfile only the worktree's checks wrote is copied from
533    /// `worktree`.
534    #[must_use]
535    pub fn fix(&self, checkout: &Path, worktree: &Path, also: &[String]) -> String {
536        let mut steps = Vec::new();
537        for f in &self.commit {
538            if !checkout.join(f).exists() {
539                steps.push(format!("cp '{}' .", worktree.join(f).display()));
540            }
541        }
542        if !self.ignore.is_empty() {
543            steps.push(format!(
544                "printf '{}\\n' >> .gitignore",
545                self.ignore.join("\\n")
546            ));
547        }
548        let mut add: Vec<&str> = also
549            .iter()
550            .chain(&self.commit)
551            .map(String::as_str)
552            .collect();
553        if !self.ignore.is_empty() {
554            add.push(".gitignore");
555        }
556        steps.push(format!(
557            "git add {} && git commit -m \"speccoder: setup\"",
558            add.join(" ")
559        ));
560        steps.join(" && ")
561    }
562}
563
564/// [`Leftovers`] in `dir`.
565#[must_use]
566pub fn leftovers(dir: &Path) -> Leftovers {
567    const BUILD: [&str; 4] = ["target", "node_modules", ".pytest_cache", "__pycache__"];
568    let Ok(out) = Git::at(dir).run(&["status", "--porcelain=v1", "--untracked-files=normal"])
569    else {
570        return Leftovers::default();
571    };
572    let mut found = Leftovers::default();
573    for p in String::from_utf8_lossy(&out)
574        .lines()
575        .filter_map(|l| l.strip_prefix("?? "))
576    {
577        let p = p.trim_end_matches('/');
578        let first = p.split('/').next().unwrap_or(p);
579        if BUILD.contains(&first) {
580            found.ignore.push(format!("{first}/"));
581        } else if LOCKFILES.contains(&p) {
582            found.commit.push(p.to_owned());
583        }
584    }
585    found.ignore.sort();
586    found.ignore.dedup();
587    found
588}
589
590/// Refuses when the working tree has any uncommitted change (for in-place mode).
591pub fn require_clean(dir: &Path) -> Result<(), Refusal> {
592    let out = Git::at(dir).run(&["status", "--porcelain=v1", "-z", "--untracked-files=all"])?;
593    let paths = status_entries(&out);
594    if paths.is_empty() {
595        Ok(())
596    } else {
597        Err(Refusal {
598            code: "DIRTY-CHECKOUT",
599            message: "the working tree has uncommitted changes".to_owned(),
600            paths,
601            hint: "commit or stash them first".to_owned(),
602        })
603    }
604}
605
606// ── Checkpoints ─────────────────────────────────────────────────────────────
607
608/// A snapshot of a working tree: a git tree object id.
609#[derive(Debug, Clone, PartialEq, Eq)]
610pub struct Checkpoint(pub String);
611
612/// What happened to a path between two checkpoints.
613#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
614pub enum ChangeKind {
615    /// Present only in the later checkpoint.
616    Added,
617    /// Content, mode or type differs.
618    Modified,
619    /// Present only in the earlier checkpoint.
620    Deleted,
621}
622
623/// One changed path (repository-relative).
624#[derive(Debug, Clone, PartialEq, Eq)]
625pub struct Change {
626    /// The path.
627    pub path: String,
628    /// What happened.
629    pub kind: ChangeKind,
630}
631
632/// A temporary index file that is removed when dropped.
633struct TempIndex(PathBuf);
634
635impl TempIndex {
636    fn new(tmp: &Path) -> Result<Self, GitError> {
637        static COUNTER: AtomicU64 = AtomicU64::new(0);
638        fs::create_dir_all(tmp).map_err(|e| GitError(format!("{}: {e}", tmp.display())))?;
639        let nanos = SystemTime::now()
640            .duration_since(UNIX_EPOCH)
641            .map(|d| d.as_nanos())
642            .unwrap_or(0);
643        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
644        Ok(Self(
645            tmp.join(format!("index-{}-{nanos}-{n}", std::process::id())),
646        ))
647    }
648}
649
650impl Drop for TempIndex {
651    fn drop(&mut self) {
652        // Our own temporary file; nothing else is ever removed here.
653        let _ = fs::remove_file(&self.0);
654    }
655}
656
657/// Snapshots the working tree of `dir`. `tmp` holds the temporary
658/// index (inside speccoder's state directory).
659pub fn checkpoint(dir: &Path, tmp: &Path) -> Result<Checkpoint, GitError> {
660    let git = Git::at(dir);
661    let index = TempIndex::new(tmp)?;
662    // Start from the real index so unchanged files are not rehashed.
663    let real = git.git_path("index")?;
664    if real.exists() {
665        fs::copy(&real, &index.0).map_err(|e| GitError(format!("copy index: {e}")))?;
666    } else if git.head()?.is_some() {
667        git.run_with_index(&index.0, &["read-tree", "HEAD"], None)?;
668    } else {
669        git.run_with_index(&index.0, &["read-tree", "--empty"], None)?;
670    }
671    // Never a file copied from the checkout ([worktree] include): a
672    // checkpoint can become a commit (a saved attempt, `keep`).
673    let excluded = git
674        .toplevel()?
675        .map(|top| crate::include::pathspecs(&top))
676        .unwrap_or_default();
677    let mut add = vec!["add", "-A", "--", ":/"];
678    add.extend(excluded.iter().map(String::as_str));
679    git.run_with_index(&index.0, &add, None)?;
680    let tree = git.run_with_index(&index.0, &["write-tree"], None)?;
681    Ok(Checkpoint(String::from_utf8_lossy(&tree).trim().to_owned()))
682}
683
684/// Paths that differ from `from` to `to`.
685pub fn diff(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<Vec<Change>, GitError> {
686    let out = Git::at(dir).run(&[
687        "diff-tree",
688        "-r",
689        "-z",
690        "--no-renames",
691        "--name-status",
692        &from.0,
693        &to.0,
694    ])?;
695    let fields = nul_fields(&out);
696    let mut changes = Vec::new();
697    for pair in fields.chunks(2) {
698        let [status, path] = pair else { continue };
699        let kind = match status.chars().next() {
700            Some('A') => ChangeKind::Added,
701            Some('D') => ChangeKind::Deleted,
702            _ => ChangeKind::Modified,
703        };
704        changes.push(Change {
705            path: path.clone(),
706            kind,
707        });
708    }
709    Ok(changes)
710}
711
712/// Every file path in a checkpoint.
713pub fn files(dir: &Path, checkpoint: &Checkpoint) -> Result<Vec<String>, GitError> {
714    let out = Git::at(dir).run(&["ls-tree", "-r", "--name-only", "-z", &checkpoint.0])?;
715    Ok(nul_fields(&out))
716}
717
718/// The textual diff between two checkpoints, without context lines.
719pub fn patch(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<String, GitError> {
720    let out = Git::at(dir).run(&["diff", "--no-color", "--no-renames", "-U0", &from.0, &to.0])?;
721    Ok(String::from_utf8_lossy(&out).into_owned())
722}
723
724/// Makes the working tree of `dir` equal to `target`: differing
725/// paths are rewritten from the checkpoint, paths the checkpoint lacks are
726/// removed. HEAD, the index and refs are not touched. Returns what changed.
727pub fn restore(dir: &Path, tmp: &Path, target: &Checkpoint) -> Result<Vec<Change>, GitError> {
728    let git = Git::at(dir);
729    let root = git
730        .toplevel()?
731        .ok_or_else(|| GitError(format!("{} is not in a git repository", dir.display())))?;
732    let current = checkpoint(&root, tmp)?;
733    let changes = diff(&root, target, &current)?;
734    if changes.is_empty() {
735        return Ok(changes);
736    }
737
738    // Changed relative to target: `Added` = only in current (remove);
739    // `Deleted`/`Modified` = take the target's version.
740    let mut write: Vec<u8> = Vec::new();
741    let mut remove: Vec<&str> = Vec::new();
742    for c in &changes {
743        if !is_plain_relative(&c.path) {
744            return Err(GitError(format!(
745                "refusing to restore unsafe path {}",
746                c.path
747            )));
748        }
749        match c.kind {
750            ChangeKind::Added => remove.push(&c.path),
751            ChangeKind::Deleted | ChangeKind::Modified => {
752                write.extend_from_slice(c.path.as_bytes());
753                write.push(0);
754            }
755        }
756    }
757    let rgit = Git::at(&root);
758    for path in &remove {
759        let full = root.join(path);
760        // A path being replaced by a directory/file of another type is handled
761        // by checkout-index below; remove the current entry without following links.
762        if fs::symlink_metadata(&full).is_ok() {
763            fs::remove_file(&full).map_err(|e| GitError(format!("remove {path}: {e}")))?;
764            prune_empty_parents(&root, &full);
765        }
766    }
767    if !write.is_empty() {
768        let index = TempIndex::new(tmp)?;
769        rgit.run_with_index(&index.0, &["read-tree", &target.0], None)?;
770        rgit.run_with_index(
771            &index.0,
772            &["checkout-index", "-f", "-z", "--stdin"],
773            Some(&write),
774        )?;
775    }
776    Ok(changes)
777}
778
779fn is_plain_relative(path: &str) -> bool {
780    let p = Path::new(path);
781    !path.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))
782}
783
784/// Removes directories left empty by a removal, up to (not including) `root`.
785fn prune_empty_parents(root: &Path, removed: &Path) {
786    let mut dir = removed.parent();
787    while let Some(d) = dir {
788        if d == root || !d.starts_with(root) || fs::remove_dir(d).is_err() {
789            break;
790        }
791        dir = d.parent();
792    }
793}