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