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