Skip to main content

atomcode_core/git/
worktree.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use anyhow::{Context, Result};
5
6pub struct Worktree {
7    pub path: PathBuf,
8    pub branch: String,
9    pub base_branch: String,
10}
11
12pub struct WorktreeManager {
13    repo_root: PathBuf,
14}
15
16impl WorktreeManager {
17    pub fn new(repo_root: PathBuf) -> Self {
18        Self { repo_root }
19    }
20
21    pub fn from_dir(dir: PathBuf) -> Result<Self> {
22        let mut cmd = Command::new("git");
23        cmd.args(["rev-parse", "--show-toplevel"])
24            .current_dir(&dir);
25        crate::process_utils::suppress_console_window_sync(&mut cmd);
26        let output = cmd.output()
27            .context("Failed to resolve git repository root")?;
28        if !output.status.success() {
29            anyhow::bail!(
30                "git rev-parse --show-toplevel failed: {}",
31                String::from_utf8_lossy(&output.stderr).trim()
32            );
33        }
34        let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
35        Ok(Self {
36            repo_root: PathBuf::from(root),
37        })
38    }
39
40    /// Create a new worktree with a new branch based on `base`.
41    pub fn create(&self, branch: &str, base: &str) -> Result<Worktree> {
42        let worktree_dir = self.worktree_base_dir();
43        std::fs::create_dir_all(&worktree_dir)?;
44        let worktree_path = worktree_dir.join(branch);
45        if worktree_path.exists() {
46            anyhow::bail!(
47                "Worktree '{}' already exists at {}",
48                branch,
49                worktree_path.display()
50            );
51        }
52        let output = {
53            let mut cmd = Command::new("git");
54            cmd.args(["worktree", "add", "-b", branch])
55                .arg(&worktree_path)
56                .arg(base)
57                .current_dir(&self.repo_root);
58            crate::process_utils::suppress_console_window_sync(&mut cmd);
59            cmd.output()
60                .context("Failed to run git worktree add")?
61        };
62        if !output.status.success() {
63            anyhow::bail!(
64                "git worktree add failed: {}",
65                String::from_utf8_lossy(&output.stderr).trim()
66            );
67        }
68        Ok(Worktree {
69            path: worktree_path,
70            branch: branch.to_string(),
71            base_branch: base.to_string(),
72        })
73    }
74
75    /// List all worktrees with branch name, path, and change status.
76    pub fn list(&self) -> Result<Vec<(String, PathBuf, bool)>> {
77        let mut cmd = Command::new("git");
78        cmd.args(["worktree", "list", "--porcelain"])
79            .current_dir(&self.repo_root);
80        crate::process_utils::suppress_console_window_sync(&mut cmd);
81        let output = cmd.output()
82            .context("Failed to run git worktree list")?;
83        let stdout = String::from_utf8_lossy(&output.stdout);
84        let mut result = Vec::new();
85        let mut current_path: Option<PathBuf> = None;
86        let mut current_branch: Option<String> = None;
87        for line in stdout.lines() {
88            if let Some(path) = line.strip_prefix("worktree ") {
89                current_path = Some(PathBuf::from(path));
90            } else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
91                current_branch = Some(branch.to_string());
92            } else if line.is_empty() {
93                if let (Some(path), Some(branch)) = (current_path.take(), current_branch.take()) {
94                    let has_changes = self.has_uncommitted_changes(&path);
95                    result.push((branch, path, has_changes));
96                }
97                current_path = None;
98                current_branch = None;
99            }
100        }
101        if let (Some(path), Some(branch)) = (current_path, current_branch) {
102            let has_changes = self.has_uncommitted_changes(&path);
103            result.push((branch, path, has_changes));
104        }
105        Ok(result)
106    }
107
108    /// Remove a worktree by branch name. Fails if there are uncommitted changes (use force).
109    pub fn remove(&self, branch: &str, force: bool) -> Result<()> {
110        let worktree_path = self
111            .find_worktree_path(branch)?
112            .unwrap_or_else(|| self.worktree_path(branch));
113        let mut args = vec!["worktree", "remove"];
114        if force {
115            args.push("--force");
116        }
117        let output = {
118            let mut cmd = Command::new("git");
119            cmd.args(&args)
120                .arg(&worktree_path)
121                .current_dir(&self.repo_root);
122            crate::process_utils::suppress_console_window_sync(&mut cmd);
123            cmd.output()
124                .context("Failed to run git worktree remove")?
125        };
126        if !output.status.success() {
127            anyhow::bail!(
128                "git worktree remove failed: {}",
129                String::from_utf8_lossy(&output.stderr).trim()
130            );
131        }
132        Ok(())
133    }
134
135    fn has_uncommitted_changes(&self, worktree_path: &Path) -> bool {
136        let mut cmd = Command::new("git");
137        cmd.args(["status", "--porcelain"])
138            .current_dir(worktree_path);
139        crate::process_utils::suppress_console_window_sync(&mut cmd);
140        cmd.output()
141            .map(|o| !o.stdout.is_empty())
142            .unwrap_or(false)
143    }
144
145    fn worktree_base_dir(&self) -> PathBuf {
146        let repo_name = self
147            .repo_root
148            .file_name()
149            .unwrap_or_default()
150            .to_string_lossy();
151        std::env::temp_dir()
152            .join("atomcode-worktrees")
153            .join(repo_name.as_ref())
154    }
155
156    pub fn worktree_path(&self, branch: &str) -> PathBuf {
157        self.worktree_base_dir().join(branch)
158    }
159
160    pub fn find_worktree_path(&self, branch: &str) -> Result<Option<PathBuf>> {
161        Ok(self
162            .list()?
163            .into_iter()
164            .find_map(|(candidate, path, _)| (candidate == branch).then_some(path)))
165    }
166
167    pub fn repo_root(&self) -> &Path {
168        &self.repo_root
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn worktree_base_dir_uses_tmp() {
178        let mgr = WorktreeManager::new(PathBuf::from("/home/user/myproject"));
179        let base = mgr.worktree_base_dir();
180        assert!(
181            base.starts_with(std::env::temp_dir()),
182            "expected base dir to start with temp_dir, got: {}",
183            base.display()
184        );
185        assert!(
186            base.ends_with("myproject"),
187            "expected base dir to end with repo name, got: {}",
188            base.display()
189        );
190    }
191
192    #[test]
193    fn worktree_base_dir_handles_root() {
194        // Must not panic when repo_root is "/".
195        let mgr = WorktreeManager::new(PathBuf::from("/"));
196        let _base = mgr.worktree_base_dir();
197    }
198
199    #[test]
200    fn from_dir_resolves_repository_root_from_subdir() {
201        let tmp = tempfile::tempdir().expect("tempdir");
202        run_git(tmp.path(), &["init"]);
203        let subdir = tmp.path().join("nested").join("crate");
204        std::fs::create_dir_all(&subdir).expect("mkdir subdir");
205
206        let mgr = WorktreeManager::from_dir(subdir).expect("resolve root");
207        assert_eq!(
208            mgr.repo_root().canonicalize().expect("canon mgr root"),
209            tmp.path().canonicalize().expect("canon tmp")
210        );
211    }
212
213    fn run_git(dir: &Path, args: &[&str]) {
214        let output = Command::new("git")
215            .args(args)
216            .current_dir(dir)
217            .output()
218            .expect("run git");
219        assert!(
220            output.status.success(),
221            "git {:?} failed: {}",
222            args,
223            String::from_utf8_lossy(&output.stderr)
224        );
225    }
226}