Skip to main content

devflow_core/
worktree.rs

1//! Git worktree operations implemented with plain `git worktree` commands.
2//!
3//! Worktrees give each coding agent an isolated working directory that shares
4//! the main repository's object database. DevFlow places them under
5//! `<project_root>/.worktrees/` so they are easy to find and clean up.
6
7use crate::git::git_command;
8use crate::phase_id::PhaseId;
9use std::path::{Path, PathBuf};
10
11/// Errors produced by worktree operations.
12#[derive(Debug, thiserror::Error)]
13pub enum WorktreeError {
14    /// Spawning git failed.
15    #[error("failed to execute git: {0}")]
16    Io(#[from] std::io::Error),
17    /// Git returned a non-success status.
18    #[error("git worktree command failed: {0}")]
19    Command(String),
20    /// The target worktree path already exists.
21    #[error("worktree path already exists: {0}")]
22    Exists(PathBuf),
23}
24
25/// One entry from `git worktree list --porcelain`.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct WorktreeInfo {
28    /// Absolute path to the worktree's working directory.
29    pub path: PathBuf,
30    /// Checked-out branch (short name), or `None` for a detached HEAD.
31    pub branch: Option<String>,
32    /// HEAD commit SHA.
33    pub head: String,
34}
35
36/// The `.worktrees` directory for a project root.
37pub fn worktrees_dir(project_root: &Path) -> PathBuf {
38    project_root.join(".worktrees")
39}
40
41/// Worktree path for a phase: `.worktrees/phase-NN`.
42pub fn phase_path(project_root: &Path, phase: PhaseId) -> PathBuf {
43    worktrees_dir(project_root).join(format!("phase-{padded}", padded = phase.padded()))
44}
45
46/// Worktree path for a single agent on a phase: `.worktrees/phase-NN-<agent>`.
47pub fn phase_agent_path(project_root: &Path, phase: PhaseId, agent: &str) -> PathBuf {
48    worktrees_dir(project_root).join(format!("phase-{padded}-{agent}", padded = phase.padded()))
49}
50
51/// Worktree path for the static reference snapshot: `.worktrees/reference`.
52pub fn reference_path(project_root: &Path) -> PathBuf {
53    worktrees_dir(project_root).join("reference")
54}
55
56/// Add a worktree.
57///
58/// When `create_branch` is set, runs `git worktree add -b <branch> <path>
59/// <start_point>` (creating `branch` off `start_point`). Otherwise runs
60/// `git worktree add <path> <branch>` to check out an existing branch.
61///
62/// Returns [`WorktreeError::Exists`] if `path` already exists — callers decide
63/// whether to remove-and-readd (refresh) or surface the error.
64pub fn add(
65    project_root: &Path,
66    path: &Path,
67    branch: &str,
68    start_point: &str,
69    create_branch: bool,
70) -> Result<(), WorktreeError> {
71    if path.exists() {
72        return Err(WorktreeError::Exists(path.to_path_buf()));
73    }
74    let path_str = path.to_string_lossy();
75    if create_branch {
76        run(
77            project_root,
78            &["worktree", "add", "-b", branch, &path_str, start_point],
79        )
80    } else {
81        run(project_root, &["worktree", "add", &path_str, branch])
82    }
83}
84
85/// Add a worktree checked out at `commitish` in **detached HEAD** state.
86///
87/// Used for the static reference snapshot: a branch already checked out in the
88/// main worktree cannot be checked out again, but it can be snapshotted detached
89/// at its tip.
90pub fn add_detached(
91    project_root: &Path,
92    path: &Path,
93    commitish: &str,
94) -> Result<(), WorktreeError> {
95    if path.exists() {
96        return Err(WorktreeError::Exists(path.to_path_buf()));
97    }
98    let path_str = path.to_string_lossy();
99    run(
100        project_root,
101        &["worktree", "add", "--detach", &path_str, commitish],
102    )
103}
104
105/// Remove a worktree directory via `git worktree remove [--force] <path>`.
106pub fn remove(project_root: &Path, path: &Path, force: bool) -> Result<(), WorktreeError> {
107    let path_str = path.to_string_lossy();
108    if force {
109        run(project_root, &["worktree", "remove", "--force", &path_str])
110    } else {
111        run(project_root, &["worktree", "remove", &path_str])
112    }
113}
114
115/// Prune stale worktree administrative entries via `git worktree prune`.
116pub fn prune(project_root: &Path) -> Result<(), WorktreeError> {
117    run(project_root, &["worktree", "prune"])
118}
119
120/// List all worktrees for the repository by parsing `--porcelain` output.
121pub fn list(project_root: &Path) -> Result<Vec<WorktreeInfo>, WorktreeError> {
122    let output = git_command(project_root)
123        .args(["worktree", "list", "--porcelain"])
124        .output()?;
125    if !output.status.success() {
126        return Err(WorktreeError::Command(stderr_or_status(&output)));
127    }
128    Ok(parse_porcelain(&String::from_utf8_lossy(&output.stdout)))
129}
130
131/// Parse `git worktree list --porcelain` output.
132///
133/// Records are separated by blank lines. Each record has a `worktree <path>`
134/// line, a `HEAD <sha>` line, and either `branch refs/heads/<name>` or
135/// `detached`.
136fn parse_porcelain(text: &str) -> Vec<WorktreeInfo> {
137    let mut result = Vec::new();
138    let mut path: Option<PathBuf> = None;
139    let mut head = String::new();
140    let mut branch: Option<String> = None;
141
142    let mut flush = |path: &mut Option<PathBuf>, head: &mut String, branch: &mut Option<String>| {
143        if let Some(p) = path.take() {
144            result.push(WorktreeInfo {
145                path: p,
146                branch: branch.take(),
147                head: std::mem::take(head),
148            });
149        } else {
150            *head = String::new();
151            *branch = None;
152        }
153    };
154
155    for line in text.lines() {
156        if line.is_empty() {
157            flush(&mut path, &mut head, &mut branch);
158            continue;
159        }
160        if let Some(p) = line.strip_prefix("worktree ") {
161            path = Some(PathBuf::from(p));
162        } else if let Some(h) = line.strip_prefix("HEAD ") {
163            head = h.to_string();
164        } else if let Some(b) = line.strip_prefix("branch ") {
165            branch = Some(b.trim_start_matches("refs/heads/").to_string());
166        }
167        // `detached`, `bare`, `locked`, etc. leave `branch` as None.
168    }
169    // Final record (porcelain output may or may not end with a blank line).
170    flush(&mut path, &mut head, &mut branch);
171    result
172}
173
174fn run(project_root: &Path, args: &[&str]) -> Result<(), WorktreeError> {
175    let output = git_command(project_root).args(args).output()?;
176    if output.status.success() {
177        Ok(())
178    } else {
179        Err(WorktreeError::Command(stderr_or_status(&output)))
180    }
181}
182
183fn stderr_or_status(output: &std::process::Output) -> String {
184    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
185    if stderr.is_empty() {
186        format!("exited with {}", output.status)
187    } else {
188        stderr
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use tempfile::TempDir;
196
197    fn git(root: &Path, args: &[&str]) {
198        let output = crate::test_support::git_command(root)
199            .args(args)
200            .output()
201            .expect("spawn git");
202        assert!(
203            output.status.success(),
204            "git {args:?} failed: {}",
205            String::from_utf8_lossy(&output.stderr)
206        );
207    }
208
209    /// Init a repo with `main` and `develop` and one commit.
210    fn init_repo() -> TempDir {
211        let dir = tempfile::tempdir().unwrap();
212        let root = dir.path();
213        git(root, &["init", "-q"]);
214        git(root, &["config", "user.email", "test@example.com"]);
215        git(root, &["config", "user.name", "Test"]);
216        git(root, &["config", "commit.gpgsign", "false"]);
217        git(root, &["config", "core.hooksPath", "/dev/null"]);
218        std::fs::write(root.join("README.md"), "base\n").unwrap();
219        git(root, &["add", "."]);
220        git(root, &["commit", "-q", "-m", "base"]);
221        git(root, &["branch", "-M", "main"]);
222        git(root, &["checkout", "-q", "-b", "develop"]);
223        dir
224    }
225
226    #[test]
227    fn path_helpers_format_phase_numbers() {
228        let root = Path::new("/repo");
229        assert_eq!(worktrees_dir(root), Path::new("/repo/.worktrees"));
230        assert_eq!(
231            phase_path(root, PhaseId::new(7)),
232            Path::new("/repo/.worktrees/phase-07")
233        );
234        assert_eq!(
235            phase_agent_path(root, PhaseId::new(7), "claude"),
236            Path::new("/repo/.worktrees/phase-07-claude")
237        );
238        assert_eq!(
239            reference_path(root),
240            Path::new("/repo/.worktrees/reference")
241        );
242    }
243
244    #[test]
245    fn add_creates_worktree_on_new_branch() {
246        let repo = init_repo();
247        let root = repo.path();
248        let wt = phase_path(root, PhaseId::new(7));
249
250        add(root, &wt, "feature/phase-07", "develop", true).expect("add");
251
252        assert!(wt.exists());
253        assert!(wt.join("README.md").exists());
254
255        let listing = list(root).expect("list");
256        let entry = listing
257            .iter()
258            .find(|w| w.path.ends_with("phase-07") || w.path == wt)
259            .expect("phase-07 worktree present");
260        assert_eq!(entry.branch.as_deref(), Some("feature/phase-07"));
261    }
262
263    #[test]
264    fn add_errors_when_path_exists() {
265        let repo = init_repo();
266        let root = repo.path();
267        let wt = phase_path(root, PhaseId::new(7));
268        add(root, &wt, "feature/phase-07", "develop", true).expect("add");
269
270        let err = add(root, &wt, "feature/phase-07b", "develop", true).unwrap_err();
271        assert!(matches!(err, WorktreeError::Exists(_)));
272    }
273
274    #[test]
275    fn list_includes_main_and_added_worktrees() {
276        let repo = init_repo();
277        let root = repo.path();
278        let before = list(root).expect("list before");
279        assert_eq!(before.len(), 1, "only the main worktree initially");
280
281        add(
282            root,
283            &phase_path(root, PhaseId::new(1)),
284            "feature/phase-01",
285            "develop",
286            true,
287        )
288        .expect("add");
289        let after = list(root).expect("list after");
290        assert_eq!(after.len(), 2);
291        assert!(after.iter().any(|w| w.branch.as_deref() == Some("develop")));
292        assert!(
293            after
294                .iter()
295                .any(|w| w.branch.as_deref() == Some("feature/phase-01"))
296        );
297    }
298
299    #[test]
300    fn remove_deletes_the_worktree() {
301        let repo = init_repo();
302        let root = repo.path();
303        let wt = phase_path(root, PhaseId::new(2));
304        add(root, &wt, "feature/phase-02", "develop", true).expect("add");
305        assert!(wt.exists());
306
307        remove(root, &wt, false).expect("remove");
308        assert!(!wt.exists());
309        let listing = list(root).expect("list");
310        assert!(!listing.iter().any(|w| w.path == wt));
311    }
312
313    #[test]
314    fn add_existing_branch_without_creating() {
315        let repo = init_repo();
316        let root = repo.path();
317        // Create a branch in the main checkout, then check it out in a worktree.
318        git(root, &["branch", "topic"]);
319        let wt = worktrees_dir(root).join("topic-wt");
320        add(root, &wt, "topic", "", false).expect("add existing branch");
321        let listing = list(root).expect("list");
322        assert!(listing.iter().any(|w| w.branch.as_deref() == Some("topic")));
323    }
324
325    #[test]
326    fn parse_porcelain_handles_detached_and_trailing_record() {
327        let text = "worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n\
328                    \nworktree /repo/.worktrees/phase-07\nHEAD def456\ndetached\n";
329        let parsed = parse_porcelain(text);
330        assert_eq!(parsed.len(), 2);
331        assert_eq!(parsed[0].path, PathBuf::from("/repo"));
332        assert_eq!(parsed[0].branch.as_deref(), Some("develop"));
333        assert_eq!(parsed[0].head, "abc123");
334        assert_eq!(parsed[1].path, PathBuf::from("/repo/.worktrees/phase-07"));
335        assert_eq!(parsed[1].branch, None);
336        assert_eq!(parsed[1].head, "def456");
337    }
338
339    #[test]
340    fn prune_succeeds_on_clean_repo() {
341        let repo = init_repo();
342        prune(repo.path()).expect("prune");
343    }
344
345    // -----------------------------------------------------------------
346    // 27-02 (D-03/T-27-03): worktree::list is immune to a hostile GIT_DIR
347    // -----------------------------------------------------------------
348
349    /// D-03/T-27-03: `list` does NOT route through the `run` chokepoint and
350    /// so needed its own, independent migration. Proven the same way
351    /// 27-01's `origin_main_ancestor_status_holds_under_a_hostile_git_dir`
352    /// proves immunity (no process-global env mutation — Rust 2024
353    /// `unsafe`, unsound under threaded tests, Phase 25 D-14), in two
354    /// parts: (a) the `Command` `list` builds via the scrubbing constructor
355    /// is unconditionally scrubbed — no bypass parameter, no env-var check,
356    /// no config lookup (D-01), asserted directly on the built `Command`;
357    /// (b) the actual `list(real_root)`
358    /// production function, called normally with nothing re-adding
359    /// `GIT_DIR` afterward, reaches the correct answer — proven when THIS
360    /// test itself runs under this crate's hostile-`GIT_DIR` harness
361    /// (`GIT_DIR=<hostile>/.git cargo test ... \
362    /// list_resolves_caller_root_under_a_hostile_git_dir`), whose OS-level
363    /// env var this test's own process (and so `list`'s spawned child,
364    /// unless scrubbed) inherits.
365    ///
366    /// A literal chained `.env("GIT_DIR", foreign)`-after-the-constructor
367    /// reproduction of `list`'s own argv (the technique
368    /// `hermetic_command_resolves_caller_root_even_under_a_hostile_git_dir`
369    /// uses for `--show-toplevel`) was deliberately NOT added here:
370    /// empirically verified (this machine, git 2.55.0) that
371    /// `worktree list --porcelain` genuinely IS redirected by an explicit
372    /// `GIT_DIR` override chained after the scrub (`cd <real> && GIT_DIR=
373    /// <foreign>/.git git worktree list --porcelain` enumerates
374    /// `<foreign>`'s own single worktree entry, not `<real>`'s) — unlike
375    /// `--show-toplevel`, which falls back to cwd when `GIT_WORK_TREE` is
376    /// unset. This is exactly T-27-03's threat: an explicit/inherited
377    /// `GIT_DIR` genuinely retargets this command, which is why the fix is
378    /// scrubbing it away before it ever reaches the child, not proving the
379    /// child resists an override that was never removed (same reasoning as
380    /// 27-01's documented deviation for `merge-base --is-ancestor`).
381    #[test]
382    fn list_resolves_caller_root_under_a_hostile_git_dir() {
383        let repo = init_repo();
384        let root = repo.path();
385        let wt_path = phase_path(root, PhaseId::new(9));
386        let wt_str = wt_path.to_string_lossy();
387        // Fixture setup goes through the already-scrubbed general
388        // constructor directly (not the production `add()`, which itself
389        // depends on `run()` — kept independent so this test proves only
390        // `list`'s own immunity, not `run`'s).
391        assert!(
392            crate::git::git_command(root)
393                .args([
394                    "worktree",
395                    "add",
396                    "-b",
397                    "feature/phase-09",
398                    &wt_str,
399                    "develop",
400                ])
401                .output()
402                .unwrap()
403                .status
404                .success(),
405            "git worktree add fixture setup failed"
406        );
407
408        // (a) unconditionally scrubbed.
409        let cmd = crate::git::git_command(root);
410        assert!(
411            cmd.get_envs()
412                .any(|(key, value)| key == "GIT_DIR" && value.is_none()),
413            "list's own Command must mark GIT_DIR for removal"
414        );
415
416        // (b) the actual, scrubbed mechanism reaches the correct answer.
417        let entries = list(root).expect("list must succeed");
418        let canonical_root = std::fs::canonicalize(root).expect("canonicalize root");
419        assert!(
420            entries.len() >= 2,
421            "expected at least main + added worktree, got: {entries:?}"
422        );
423        for entry in &entries {
424            let canonical_entry =
425                std::fs::canonicalize(&entry.path).expect("canonicalize entry path");
426            assert!(
427                canonical_entry.starts_with(&canonical_root),
428                "worktree entry {canonical_entry:?} must be under real_root {canonical_root:?}"
429            );
430        }
431        assert!(
432            entries
433                .iter()
434                .any(|w| w.branch.as_deref() == Some("feature/phase-09")),
435            "list must include the added worktree, got: {entries:?}"
436        );
437    }
438}