atomcode_core/git/
worktree.rs1use 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 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 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 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 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}