1use std::path::{Path, PathBuf};
8use std::process::Command;
9
10#[derive(Debug, thiserror::Error)]
12pub enum WorktreeError {
13 #[error("failed to execute git: {0}")]
15 Io(#[from] std::io::Error),
16 #[error("git worktree command failed: {0}")]
18 Command(String),
19 #[error("worktree path already exists: {0}")]
21 Exists(PathBuf),
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct WorktreeInfo {
27 pub path: PathBuf,
29 pub branch: Option<String>,
31 pub head: String,
33}
34
35pub fn worktrees_dir(project_root: &Path) -> PathBuf {
37 project_root.join(".worktrees")
38}
39
40pub fn phase_path(project_root: &Path, phase: u32) -> PathBuf {
42 worktrees_dir(project_root).join(format!("phase-{phase:02}"))
43}
44
45pub 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
50pub fn reference_path(project_root: &Path) -> PathBuf {
52 worktrees_dir(project_root).join("reference")
53}
54
55pub 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
84pub 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
104pub 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
114pub fn prune(project_root: &Path) -> Result<(), WorktreeError> {
116 run(project_root, &["worktree", "prune"])
117}
118
119pub fn list(project_root: &Path) -> Result<Vec<WorktreeInfo>, WorktreeError> {
121 let output = Command::new("git")
122 .args(["worktree", "list", "--porcelain"])
123 .current_dir(project_root)
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
131fn 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 }
169 flush(&mut path, &mut head, &mut branch);
171 result
172}
173
174fn run(project_root: &Path, args: &[&str]) -> Result<(), WorktreeError> {
175 let output = Command::new("git")
176 .args(args)
177 .current_dir(project_root)
178 .output()?;
179 if output.status.success() {
180 Ok(())
181 } else {
182 Err(WorktreeError::Command(stderr_or_status(&output)))
183 }
184}
185
186fn stderr_or_status(output: &std::process::Output) -> String {
187 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
188 if stderr.is_empty() {
189 format!("exited with {}", output.status)
190 } else {
191 stderr
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use std::process::Command;
199 use tempfile::TempDir;
200
201 fn git(root: &Path, args: &[&str]) {
202 let output = Command::new("git")
203 .args(args)
204 .current_dir(root)
205 .output()
206 .expect("spawn git");
207 assert!(
208 output.status.success(),
209 "git {args:?} failed: {}",
210 String::from_utf8_lossy(&output.stderr)
211 );
212 }
213
214 fn init_repo() -> TempDir {
216 let dir = tempfile::tempdir().unwrap();
217 let root = dir.path();
218 git(root, &["init", "-q"]);
219 git(root, &["config", "user.email", "test@example.com"]);
220 git(root, &["config", "user.name", "Test"]);
221 git(root, &["config", "commit.gpgsign", "false"]);
222 git(root, &["config", "core.hooksPath", "/dev/null"]);
223 std::fs::write(root.join("README.md"), "base\n").unwrap();
224 git(root, &["add", "."]);
225 git(root, &["commit", "-q", "-m", "base"]);
226 git(root, &["branch", "-M", "main"]);
227 git(root, &["checkout", "-q", "-b", "develop"]);
228 dir
229 }
230
231 #[test]
232 fn path_helpers_format_phase_numbers() {
233 let root = Path::new("/repo");
234 assert_eq!(worktrees_dir(root), Path::new("/repo/.worktrees"));
235 assert_eq!(phase_path(root, 7), Path::new("/repo/.worktrees/phase-07"));
236 assert_eq!(
237 phase_agent_path(root, 7, "claude"),
238 Path::new("/repo/.worktrees/phase-07-claude")
239 );
240 assert_eq!(
241 reference_path(root),
242 Path::new("/repo/.worktrees/reference")
243 );
244 }
245
246 #[test]
247 fn add_creates_worktree_on_new_branch() {
248 let repo = init_repo();
249 let root = repo.path();
250 let wt = phase_path(root, 7);
251
252 add(root, &wt, "feature/phase-07", "develop", true).expect("add");
253
254 assert!(wt.exists());
255 assert!(wt.join("README.md").exists());
256
257 let listing = list(root).expect("list");
258 let entry = listing
259 .iter()
260 .find(|w| w.path.ends_with("phase-07") || w.path == wt)
261 .expect("phase-07 worktree present");
262 assert_eq!(entry.branch.as_deref(), Some("feature/phase-07"));
263 }
264
265 #[test]
266 fn add_errors_when_path_exists() {
267 let repo = init_repo();
268 let root = repo.path();
269 let wt = phase_path(root, 7);
270 add(root, &wt, "feature/phase-07", "develop", true).expect("add");
271
272 let err = add(root, &wt, "feature/phase-07b", "develop", true).unwrap_err();
273 assert!(matches!(err, WorktreeError::Exists(_)));
274 }
275
276 #[test]
277 fn list_includes_main_and_added_worktrees() {
278 let repo = init_repo();
279 let root = repo.path();
280 let before = list(root).expect("list before");
281 assert_eq!(before.len(), 1, "only the main worktree initially");
282
283 add(
284 root,
285 &phase_path(root, 1),
286 "feature/phase-01",
287 "develop",
288 true,
289 )
290 .expect("add");
291 let after = list(root).expect("list after");
292 assert_eq!(after.len(), 2);
293 assert!(after.iter().any(|w| w.branch.as_deref() == Some("develop")));
294 assert!(
295 after
296 .iter()
297 .any(|w| w.branch.as_deref() == Some("feature/phase-01"))
298 );
299 }
300
301 #[test]
302 fn remove_deletes_the_worktree() {
303 let repo = init_repo();
304 let root = repo.path();
305 let wt = phase_path(root, 2);
306 add(root, &wt, "feature/phase-02", "develop", true).expect("add");
307 assert!(wt.exists());
308
309 remove(root, &wt, false).expect("remove");
310 assert!(!wt.exists());
311 let listing = list(root).expect("list");
312 assert!(!listing.iter().any(|w| w.path == wt));
313 }
314
315 #[test]
316 fn add_existing_branch_without_creating() {
317 let repo = init_repo();
318 let root = repo.path();
319 git(root, &["branch", "topic"]);
321 let wt = worktrees_dir(root).join("topic-wt");
322 add(root, &wt, "topic", "", false).expect("add existing branch");
323 let listing = list(root).expect("list");
324 assert!(listing.iter().any(|w| w.branch.as_deref() == Some("topic")));
325 }
326
327 #[test]
328 fn parse_porcelain_handles_detached_and_trailing_record() {
329 let text = "worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n\
330 \nworktree /repo/.worktrees/phase-07\nHEAD def456\ndetached\n";
331 let parsed = parse_porcelain(text);
332 assert_eq!(parsed.len(), 2);
333 assert_eq!(parsed[0].path, PathBuf::from("/repo"));
334 assert_eq!(parsed[0].branch.as_deref(), Some("develop"));
335 assert_eq!(parsed[0].head, "abc123");
336 assert_eq!(parsed[1].path, PathBuf::from("/repo/.worktrees/phase-07"));
337 assert_eq!(parsed[1].branch, None);
338 assert_eq!(parsed[1].head, "def456");
339 }
340
341 #[test]
342 fn prune_succeeds_on_clean_repo() {
343 let repo = init_repo();
344 prune(repo.path()).expect("prune");
345 }
346}