Skip to main content

git_claw/infra/
worktree.rs

1//! Git worktree lifecycle runner primitives.
2
3use crate::infra::error::WorktreeError;
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7
8/// Creates a new worktree at `worktree_path` checked out on a newly created `branch` based on `base_branch`.
9/// Cleans up any partially created directory if `git worktree add` fails.
10pub fn create_worktree(
11    repo_root: &Path,
12    worktree_path: &Path,
13    branch: &str,
14    base_branch: &str,
15) -> Result<(), WorktreeError> {
16    if worktree_path.exists() {
17        // If the directory exists but does not contain a .git file/directory (orphaned leftover e.g. from IDE),
18        // safely purge the stale directory instead of aborting.
19        if !worktree_path.join(".git").exists() {
20            let _ = fs::remove_dir_all(worktree_path);
21        } else {
22            return Err(WorktreeError::DirectoryAlreadyExists(
23                worktree_path.to_string_lossy().to_string(),
24            ));
25        }
26    }
27
28    if let Some(parent) = worktree_path.parent() {
29        fs::create_dir_all(parent)?;
30    }
31
32    let mut cmd = Command::new("git");
33    cmd.current_dir(repo_root);
34    cmd.args([
35        "worktree",
36        "add",
37        "-b",
38        branch,
39        &worktree_path.to_string_lossy(),
40        base_branch,
41    ]);
42
43    let output = cmd.output()?;
44    if !output.status.success() {
45        let stderr = String::from_utf8_lossy(&output.stderr);
46
47        // Attempt rollback of created directory if it exists
48        if worktree_path.exists() {
49            let _ = fs::remove_dir_all(worktree_path);
50        }
51
52        // Clean up git metadata
53        let mut prune_cmd = Command::new("git");
54        prune_cmd.current_dir(repo_root);
55        prune_cmd.args(["worktree", "prune"]);
56        let _ = prune_cmd.output();
57
58        if stderr.contains("already exists") {
59            return Err(WorktreeError::BranchAlreadyExists(branch.to_string()));
60        }
61
62        return Err(WorktreeError::CreationFailed {
63            path: worktree_path.to_string_lossy().to_string(),
64            message: stderr.trim().to_string(),
65        });
66    }
67
68    Ok(())
69}
70
71/// Removes a worktree via `git worktree remove` (optionally `--force`) and prunes worktree metadata.
72pub fn remove_worktree(
73    repo_root: &Path,
74    worktree_path: &Path,
75    force: bool,
76) -> Result<(), WorktreeError> {
77    let mut cmd = Command::new("git");
78    cmd.current_dir(repo_root);
79    cmd.arg("worktree");
80    cmd.arg("remove");
81    if force {
82        cmd.arg("--force");
83    }
84    cmd.arg(worktree_path);
85
86    let output = cmd.output()?;
87    if !output.status.success() {
88        let stderr = String::from_utf8_lossy(&output.stderr);
89        // If git failed because worktree was already unregistered in git metadata,
90        // still remove the directory if force is requested.
91        if !worktree_path.exists() {
92            return Err(WorktreeError::RemovalFailed {
93                path: worktree_path.to_string_lossy().to_string(),
94                message: stderr.trim().to_string(),
95            });
96        }
97    }
98
99    // Completely remove leftover directory on disk (untracked files, IDE config, caches)
100    if force && worktree_path.exists() {
101        let _ = fs::remove_dir_all(worktree_path);
102    }
103
104    // Prune stale metadata
105    let mut prune = Command::new("git");
106    prune.current_dir(repo_root);
107    prune.args(["worktree", "prune"]);
108    let _ = prune.output();
109
110    Ok(())
111}
112
113/// Deletes a local Git branch (`-d` or `-D` if force).
114pub fn delete_branch(repo_root: &Path, branch: &str, force: bool) -> Result<(), WorktreeError> {
115    let mut cmd = Command::new("git");
116    cmd.current_dir(repo_root);
117    cmd.arg("branch");
118    if force {
119        cmd.arg("-D");
120    } else {
121        cmd.arg("-d");
122    }
123    cmd.arg(branch);
124
125    let output = cmd.output()?;
126    if !output.status.success() {
127        let stderr = String::from_utf8_lossy(&output.stderr);
128        return Err(WorktreeError::RemovalFailed {
129            path: branch.to_string(),
130            message: stderr.trim().to_string(),
131        });
132    }
133
134    Ok(())
135}