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/// Creates or reuses the worktree for `feature`.
106///
107/// `repo_root` is the git working tree of the user's checkout; `committed`
108/// says which repository-relative spec paths must be committed.
109pub fn ensure_worktree(
110    repo_root: &Path,
111    state: &Path,
112    feature: &str,
113    committed: impl Into<CommitPrecondition>,
114) -> Result<Worktree, Refusal> {
115    let artifacts = &committed.into();
116    let repo = Git::at(repo_root);
117    let Some(_head) = repo.head()? else {
118        return Err(Refusal {
119            code: "NO-REPOSITORY",
120            ..Refusal::new(
121                "the repository has no commits yet; a worktree needs one",
122                artifacts.no_commits_hint,
123            )
124        });
125    };
126    require_committed(&repo, artifacts)?;
127
128    let branch = format!("speccoder/{feature}");
129    let path = repo_state_dir(state, &repo)?
130        .join("worktrees")
131        .join(feature);
132
133    // Forget worktrees whose directories were removed by hand.
134    repo.run(&["worktree", "prune"])?;
135    if let Some(existing) = worktree_for(&repo, &branch)? {
136        bring_spec_changes(&repo, &existing, &branch, artifacts)?;
137        let base = Git::at(&existing).line(&["rev-parse", "HEAD"])?;
138        return Ok(Worktree {
139            path: existing,
140            branch,
141            base,
142            created: false,
143        });
144    }
145    if path.exists() {
146        return Err(Refusal::new(
147            format!(
148                "{} exists but is not a worktree of this repository",
149                path.display()
150            ),
151            "move it away; speccoder does not delete directories it did not create",
152        ));
153    }
154    if let Some(parent) = path.parent() {
155        fs::create_dir_all(parent).map_err(|e| {
156            Refusal::new(
157                format!("{}: {e}", parent.display()),
158                "check permissions of the state directory",
159            )
160        })?;
161    }
162    let path_str = path.to_string_lossy().into_owned();
163    let branch_ref = format!("refs/heads/{branch}");
164    if repo.succeeds(&["rev-parse", "--verify", "-q", &branch_ref])? {
165        // The branch survived its worktree: re-attach, keeping its history.
166        repo.run(&["worktree", "add", "--quiet", &path_str, &branch])?;
167    } else {
168        repo.run(&[
169            "worktree", "add", "--quiet", "-b", &branch, &path_str, "HEAD",
170        ])?;
171    }
172    let path = fs::canonicalize(&path).unwrap_or(path);
173    let base = Git::at(&path).line(&["rev-parse", "HEAD"])?;
174    Ok(Worktree {
175        path,
176        branch,
177        base,
178        created: true,
179    })
180}
181
182/// When the person's checkout changed the spec artifacts after the
183/// speccoder branch forked (a fixed example, a clarified requirement), the
184/// worktree would work from the old spec: merge their `HEAD` in first. A
185/// conflict is theirs to resolve; nothing is half-merged.
186fn bring_spec_changes(
187    repo: &Git,
188    worktree: &Path,
189    branch: &str,
190    artifacts: &CommitPrecondition,
191) -> Result<(), Refusal> {
192    let head = repo.line(&["rev-parse", "HEAD"])?;
193    let base = repo.line(&["merge-base", branch, &head])?;
194    let paths = artifacts.paths();
195    let mut diff = vec!["diff", "--quiet", &base, &head, "--"];
196    diff.extend(paths.iter().map(String::as_str));
197    let moved = !repo.succeeds(&diff)?;
198    if !moved {
199        return Ok(());
200    }
201    let wt = Git::at(worktree);
202    let merged = wt.run(&[
203        "-c",
204        "user.name=speccoder",
205        "-c",
206        "user.email=speccoder@users.noreply.invalid",
207        "-c",
208        "commit.gpgsign=false",
209        "merge",
210        "-q",
211        "--no-edit",
212        "-m",
213        "speccoder: bring in the spec changes from the checkout",
214        &head,
215    ]);
216    if let Err(e) = merged {
217        let _ = wt.run(&["merge", "--abort"]);
218        return Err(Refusal::new(
219            format!(
220                "the spec changed in your checkout and could not be merged into {branch}: {}",
221                e.0.lines().next().unwrap_or_default()
222            ),
223            format!(
224                "merge it by hand in {} (git merge {head}), then run again",
225                worktree.display()
226            ),
227        ));
228    }
229    Ok(())
230}
231
232/// The branch of a lane: one task of `feature` run in parallel.
233#[must_use]
234pub fn lane_branch(feature: &str, lane: &str) -> String {
235    format!("speccoder-lane/{feature}/{lane}")
236}
237
238/// A fresh lane worktree for `lane` of `feature`, started at `start` (the
239/// feature branch's tip). A lane left behind is replaced: lanes are
240/// speccoder's scratch space; their work reaches the feature branch only
241/// by merging.
242pub fn ensure_lane(
243    repo_root: &Path,
244    state: &Path,
245    feature: &str,
246    lane: &str,
247    start: &str,
248) -> Result<Worktree, Refusal> {
249    let repo = Git::at(repo_root);
250    let branch = lane_branch(feature, lane);
251    let path = repo_state_dir(state, &repo)?
252        .join("worktrees")
253        .join(format!("{feature}@{lane}"));
254    remove_lane(repo_root, feature, lane)?;
255    if path.exists() {
256        return Err(Refusal::new(
257            format!(
258                "{} exists but is not a worktree of this repository",
259                path.display()
260            ),
261            "move it away; speccoder does not delete directories it did not create",
262        ));
263    }
264    if let Some(parent) = path.parent() {
265        let _ = fs::create_dir_all(parent);
266    }
267    let path_str = path.to_string_lossy().into_owned();
268    repo.run(&[
269        "worktree", "add", "--quiet", "-B", &branch, &path_str, start,
270    ])?;
271    let path = fs::canonicalize(&path).unwrap_or(path);
272    Ok(Worktree {
273        path,
274        branch,
275        base: start.to_owned(),
276        created: true,
277    })
278}
279
280/// Removes `lane`'s worktree and branch, if there are any.
281pub fn remove_lane(repo_root: &Path, feature: &str, lane: &str) -> Result<(), GitError> {
282    let repo = Git::at(repo_root);
283    let branch = lane_branch(feature, lane);
284    repo.run(&["worktree", "prune"])?;
285    if let Some(existing) = worktree_for(&repo, &branch)? {
286        repo.run(&["worktree", "remove", "--force", &existing.to_string_lossy()])?;
287    }
288    if repo.succeeds(&[
289        "rev-parse",
290        "--verify",
291        "-q",
292        &format!("refs/heads/{branch}"),
293    ])? {
294        repo.run(&["branch", "-q", "-D", &branch])?;
295    }
296    Ok(())
297}
298
299/// The worktree checked out on `branch`, if any.
300fn worktree_for(repo: &Git, branch: &str) -> Result<Option<PathBuf>, GitError> {
301    let fields = nul_fields(&repo.run(&["worktree", "list", "--porcelain", "-z"])?);
302    let wanted = format!("branch refs/heads/{branch}");
303    let mut current: Option<PathBuf> = None;
304    for f in fields {
305        if let Some(p) = f.strip_prefix("worktree ") {
306            current = Some(PathBuf::from(p));
307        } else if f == wanted {
308            return Ok(current);
309        }
310    }
311    Ok(None)
312}
313
314/// Spec Kit paths a worktree must contain.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct Artifacts {
317    /// Repository-relative `.specify` directory.
318    pub specify_dir: String,
319    /// Repository-relative feature directory, e.g. `specs/001-auth`.
320    pub feature_dir: String,
321}
322
323impl From<&Artifacts> for CommitPrecondition {
324    fn from(a: &Artifacts) -> Self {
325        crate::format::speckit::precondition(&a.specify_dir, &a.feature_dir)
326    }
327}
328
329/// Refuses unless the spec artifacts are committed as they are on disk:
330/// a worktree contains only commits, so anything else would be an agent
331/// working from a different spec than the user's.
332fn require_committed(repo: &Git, a: &CommitPrecondition) -> Result<(), Refusal> {
333    let mut paths: Vec<(String, String)> = Vec::new();
334
335    for dir in &a.strict {
336        let out = repo.run(&[
337            "status",
338            "--porcelain=v1",
339            "-z",
340            "--untracked-files=all",
341            "--ignored=traditional",
342            "--",
343            dir,
344        ])?;
345        paths.extend(status_entries(&out));
346    }
347    for dir in &a.tracked {
348        let out = repo.run(&[
349            "status",
350            "--porcelain=v1",
351            "-z",
352            "--untracked-files=all",
353            "--",
354            dir,
355        ])?;
356        paths.extend(
357            status_entries(&out)
358                .into_iter()
359                .filter(|(p, _)| !(a.runtime)(p)),
360        );
361    }
362
363    // And the essentials must be tracked at all (a fully ignored directory
364    // has no status entries of its own here).
365    for p in &a.essentials {
366        let tracked = !repo.run(&["ls-files", "--", p])?.is_empty();
367        if !tracked && repo.dir().join(p).exists() && !paths.iter().any(|(q, _)| q == p) {
368            let reason = if repo.succeeds(&["check-ignore", "-q", "--", p])? {
369                "ignored"
370            } else {
371                "untracked"
372            };
373            paths.push((p.clone(), reason.to_owned()));
374        }
375    }
376
377    if paths.is_empty() {
378        return Ok(());
379    }
380    paths.sort();
381    paths.dedup();
382    Err(Refusal {
383        code: "UNCOMMITTED-INTENT",
384        message: a.message.to_owned(),
385        paths,
386        hint: a.hint.to_owned(),
387    })
388}
389
390/// `git status --porcelain=v1 -z` entries as (path, reason).
391fn status_entries(out: &[u8]) -> Vec<(String, String)> {
392    let mut entries = Vec::new();
393    let mut fields = nul_fields(out).into_iter();
394    while let Some(f) = fields.next() {
395        if f.len() < 4 {
396            continue;
397        }
398        let (code, path) = f.split_at(3);
399        let code = code.trim_end();
400        let reason = match code {
401            "??" => "untracked",
402            "!!" => "ignored",
403            _ => "modified",
404        };
405        // Renames carry the original path as a second field.
406        if code.starts_with('R') || code.starts_with('C') {
407            fields.next();
408        }
409        entries.push((path.to_owned(), reason.to_owned()));
410    }
411    entries
412}
413
414/// Refuses when the working tree has any uncommitted change (for in-place mode).
415pub fn require_clean(dir: &Path) -> Result<(), Refusal> {
416    let out = Git::at(dir).run(&["status", "--porcelain=v1", "-z", "--untracked-files=all"])?;
417    let paths = status_entries(&out);
418    if paths.is_empty() {
419        Ok(())
420    } else {
421        Err(Refusal {
422            code: "DIRTY-CHECKOUT",
423            message: "the working tree has uncommitted changes".to_owned(),
424            paths,
425            hint: "commit or stash them first".to_owned(),
426        })
427    }
428}
429
430// ── Checkpoints ─────────────────────────────────────────────────────────────
431
432/// A snapshot of a working tree: a git tree object id.
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct Checkpoint(pub String);
435
436/// What happened to a path between two checkpoints.
437#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
438pub enum ChangeKind {
439    /// Present only in the later checkpoint.
440    Added,
441    /// Content, mode or type differs.
442    Modified,
443    /// Present only in the earlier checkpoint.
444    Deleted,
445}
446
447/// One changed path (repository-relative).
448#[derive(Debug, Clone, PartialEq, Eq)]
449pub struct Change {
450    /// The path.
451    pub path: String,
452    /// What happened.
453    pub kind: ChangeKind,
454}
455
456/// A temporary index file that is removed when dropped.
457struct TempIndex(PathBuf);
458
459impl TempIndex {
460    fn new(tmp: &Path) -> Result<Self, GitError> {
461        static COUNTER: AtomicU64 = AtomicU64::new(0);
462        fs::create_dir_all(tmp).map_err(|e| GitError(format!("{}: {e}", tmp.display())))?;
463        let nanos = SystemTime::now()
464            .duration_since(UNIX_EPOCH)
465            .map(|d| d.as_nanos())
466            .unwrap_or(0);
467        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
468        Ok(Self(
469            tmp.join(format!("index-{}-{nanos}-{n}", std::process::id())),
470        ))
471    }
472}
473
474impl Drop for TempIndex {
475    fn drop(&mut self) {
476        // Our own temporary file; nothing else is ever removed here.
477        let _ = fs::remove_file(&self.0);
478    }
479}
480
481/// Snapshots the working tree of `dir`. `tmp` holds the temporary
482/// index (inside speccoder's state directory).
483pub fn checkpoint(dir: &Path, tmp: &Path) -> Result<Checkpoint, GitError> {
484    let git = Git::at(dir);
485    let index = TempIndex::new(tmp)?;
486    // Start from the real index so unchanged files are not rehashed.
487    let real = git.git_path("index")?;
488    if real.exists() {
489        fs::copy(&real, &index.0).map_err(|e| GitError(format!("copy index: {e}")))?;
490    } else if git.head()?.is_some() {
491        git.run_with_index(&index.0, &["read-tree", "HEAD"], None)?;
492    } else {
493        git.run_with_index(&index.0, &["read-tree", "--empty"], None)?;
494    }
495    git.run_with_index(&index.0, &["add", "-A", "--", ":/"], None)?;
496    let tree = git.run_with_index(&index.0, &["write-tree"], None)?;
497    Ok(Checkpoint(String::from_utf8_lossy(&tree).trim().to_owned()))
498}
499
500/// Paths that differ from `from` to `to`.
501pub fn diff(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<Vec<Change>, GitError> {
502    let out = Git::at(dir).run(&[
503        "diff-tree",
504        "-r",
505        "-z",
506        "--no-renames",
507        "--name-status",
508        &from.0,
509        &to.0,
510    ])?;
511    let fields = nul_fields(&out);
512    let mut changes = Vec::new();
513    for pair in fields.chunks(2) {
514        let [status, path] = pair else { continue };
515        let kind = match status.chars().next() {
516            Some('A') => ChangeKind::Added,
517            Some('D') => ChangeKind::Deleted,
518            _ => ChangeKind::Modified,
519        };
520        changes.push(Change {
521            path: path.clone(),
522            kind,
523        });
524    }
525    Ok(changes)
526}
527
528/// Every file path in a checkpoint.
529pub fn files(dir: &Path, checkpoint: &Checkpoint) -> Result<Vec<String>, GitError> {
530    let out = Git::at(dir).run(&["ls-tree", "-r", "--name-only", "-z", &checkpoint.0])?;
531    Ok(nul_fields(&out))
532}
533
534/// The textual diff between two checkpoints, without context lines.
535pub fn patch(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<String, GitError> {
536    let out = Git::at(dir).run(&["diff", "--no-color", "--no-renames", "-U0", &from.0, &to.0])?;
537    Ok(String::from_utf8_lossy(&out).into_owned())
538}
539
540/// Makes the working tree of `dir` equal to `target`: differing
541/// paths are rewritten from the checkpoint, paths the checkpoint lacks are
542/// removed. HEAD, the index and refs are not touched. Returns what changed.
543pub fn restore(dir: &Path, tmp: &Path, target: &Checkpoint) -> Result<Vec<Change>, GitError> {
544    let git = Git::at(dir);
545    let root = git
546        .toplevel()?
547        .ok_or_else(|| GitError(format!("{} is not in a git repository", dir.display())))?;
548    let current = checkpoint(&root, tmp)?;
549    let changes = diff(&root, target, &current)?;
550    if changes.is_empty() {
551        return Ok(changes);
552    }
553
554    // Changed relative to target: `Added` = only in current (remove);
555    // `Deleted`/`Modified` = take the target's version.
556    let mut write: Vec<u8> = Vec::new();
557    let mut remove: Vec<&str> = Vec::new();
558    for c in &changes {
559        if !is_plain_relative(&c.path) {
560            return Err(GitError(format!(
561                "refusing to restore unsafe path {}",
562                c.path
563            )));
564        }
565        match c.kind {
566            ChangeKind::Added => remove.push(&c.path),
567            ChangeKind::Deleted | ChangeKind::Modified => {
568                write.extend_from_slice(c.path.as_bytes());
569                write.push(0);
570            }
571        }
572    }
573    let rgit = Git::at(&root);
574    for path in &remove {
575        let full = root.join(path);
576        // A path being replaced by a directory/file of another type is handled
577        // by checkout-index below; remove the current entry without following links.
578        if fs::symlink_metadata(&full).is_ok() {
579            fs::remove_file(&full).map_err(|e| GitError(format!("remove {path}: {e}")))?;
580            prune_empty_parents(&root, &full);
581        }
582    }
583    if !write.is_empty() {
584        let index = TempIndex::new(tmp)?;
585        rgit.run_with_index(&index.0, &["read-tree", &target.0], None)?;
586        rgit.run_with_index(
587            &index.0,
588            &["checkout-index", "-f", "-z", "--stdin"],
589            Some(&write),
590        )?;
591    }
592    Ok(changes)
593}
594
595fn is_plain_relative(path: &str) -> bool {
596    let p = Path::new(path);
597    !path.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))
598}
599
600/// Removes directories left empty by a removal, up to (not including) `root`.
601fn prune_empty_parents(root: &Path, removed: &Path) {
602    let mut dir = removed.parent();
603    while let Some(d) = dir {
604        if d == root || !d.starts_with(root) || fs::remove_dir(d).is_err() {
605            break;
606        }
607        dir = d.parent();
608    }
609}