1use crate::git::git_command;
8use crate::phase_id::PhaseId;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, thiserror::Error)]
13pub enum WorktreeError {
14 #[error("failed to execute git: {0}")]
16 Io(#[from] std::io::Error),
17 #[error("git worktree command failed: {0}")]
19 Command(String),
20 #[error("worktree path already exists: {0}")]
22 Exists(PathBuf),
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct WorktreeInfo {
28 pub path: PathBuf,
30 pub branch: Option<String>,
32 pub head: String,
34}
35
36pub fn worktrees_dir(project_root: &Path) -> PathBuf {
38 project_root.join(".worktrees")
39}
40
41pub fn phase_path(project_root: &Path, phase: PhaseId) -> PathBuf {
43 worktrees_dir(project_root).join(format!("phase-{padded}", padded = phase.padded()))
44}
45
46pub fn phase_agent_path(project_root: &Path, phase: PhaseId, agent: &str) -> PathBuf {
48 worktrees_dir(project_root).join(format!("phase-{padded}-{agent}", padded = phase.padded()))
49}
50
51pub fn reference_path(project_root: &Path) -> PathBuf {
53 worktrees_dir(project_root).join("reference")
54}
55
56pub fn add(
65 project_root: &Path,
66 path: &Path,
67 branch: &str,
68 start_point: &str,
69 create_branch: bool,
70) -> Result<(), WorktreeError> {
71 if path.exists() {
72 return Err(WorktreeError::Exists(path.to_path_buf()));
73 }
74 let path_str = path.to_string_lossy();
75 if create_branch {
76 run(
77 project_root,
78 &["worktree", "add", "-b", branch, &path_str, start_point],
79 )
80 } else {
81 run(project_root, &["worktree", "add", &path_str, branch])
82 }
83}
84
85pub fn add_detached(
91 project_root: &Path,
92 path: &Path,
93 commitish: &str,
94) -> Result<(), WorktreeError> {
95 if path.exists() {
96 return Err(WorktreeError::Exists(path.to_path_buf()));
97 }
98 let path_str = path.to_string_lossy();
99 run(
100 project_root,
101 &["worktree", "add", "--detach", &path_str, commitish],
102 )
103}
104
105pub fn remove(project_root: &Path, path: &Path, force: bool) -> Result<(), WorktreeError> {
107 let path_str = path.to_string_lossy();
108 if force {
109 run(project_root, &["worktree", "remove", "--force", &path_str])
110 } else {
111 run(project_root, &["worktree", "remove", &path_str])
112 }
113}
114
115pub fn prune(project_root: &Path) -> Result<(), WorktreeError> {
117 run(project_root, &["worktree", "prune"])
118}
119
120pub fn list(project_root: &Path) -> Result<Vec<WorktreeInfo>, WorktreeError> {
122 let output = git_command(project_root)
123 .args(["worktree", "list", "--porcelain"])
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 = git_command(project_root).args(args).output()?;
176 if output.status.success() {
177 Ok(())
178 } else {
179 Err(WorktreeError::Command(stderr_or_status(&output)))
180 }
181}
182
183fn stderr_or_status(output: &std::process::Output) -> String {
184 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
185 if stderr.is_empty() {
186 format!("exited with {}", output.status)
187 } else {
188 stderr
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use tempfile::TempDir;
196
197 fn git(root: &Path, args: &[&str]) {
198 let output = crate::test_support::git_command(root)
199 .args(args)
200 .output()
201 .expect("spawn git");
202 assert!(
203 output.status.success(),
204 "git {args:?} failed: {}",
205 String::from_utf8_lossy(&output.stderr)
206 );
207 }
208
209 fn init_repo() -> TempDir {
211 let dir = tempfile::tempdir().unwrap();
212 let root = dir.path();
213 git(root, &["init", "-q"]);
214 git(root, &["config", "user.email", "test@example.com"]);
215 git(root, &["config", "user.name", "Test"]);
216 git(root, &["config", "commit.gpgsign", "false"]);
217 git(root, &["config", "core.hooksPath", "/dev/null"]);
218 std::fs::write(root.join("README.md"), "base\n").unwrap();
219 git(root, &["add", "."]);
220 git(root, &["commit", "-q", "-m", "base"]);
221 git(root, &["branch", "-M", "main"]);
222 git(root, &["checkout", "-q", "-b", "develop"]);
223 dir
224 }
225
226 #[test]
227 fn path_helpers_format_phase_numbers() {
228 let root = Path::new("/repo");
229 assert_eq!(worktrees_dir(root), Path::new("/repo/.worktrees"));
230 assert_eq!(
231 phase_path(root, PhaseId::new(7)),
232 Path::new("/repo/.worktrees/phase-07")
233 );
234 assert_eq!(
235 phase_agent_path(root, PhaseId::new(7), "claude"),
236 Path::new("/repo/.worktrees/phase-07-claude")
237 );
238 assert_eq!(
239 reference_path(root),
240 Path::new("/repo/.worktrees/reference")
241 );
242 }
243
244 #[test]
245 fn add_creates_worktree_on_new_branch() {
246 let repo = init_repo();
247 let root = repo.path();
248 let wt = phase_path(root, PhaseId::new(7));
249
250 add(root, &wt, "feature/phase-07", "develop", true).expect("add");
251
252 assert!(wt.exists());
253 assert!(wt.join("README.md").exists());
254
255 let listing = list(root).expect("list");
256 let entry = listing
257 .iter()
258 .find(|w| w.path.ends_with("phase-07") || w.path == wt)
259 .expect("phase-07 worktree present");
260 assert_eq!(entry.branch.as_deref(), Some("feature/phase-07"));
261 }
262
263 #[test]
264 fn add_errors_when_path_exists() {
265 let repo = init_repo();
266 let root = repo.path();
267 let wt = phase_path(root, PhaseId::new(7));
268 add(root, &wt, "feature/phase-07", "develop", true).expect("add");
269
270 let err = add(root, &wt, "feature/phase-07b", "develop", true).unwrap_err();
271 assert!(matches!(err, WorktreeError::Exists(_)));
272 }
273
274 #[test]
275 fn list_includes_main_and_added_worktrees() {
276 let repo = init_repo();
277 let root = repo.path();
278 let before = list(root).expect("list before");
279 assert_eq!(before.len(), 1, "only the main worktree initially");
280
281 add(
282 root,
283 &phase_path(root, PhaseId::new(1)),
284 "feature/phase-01",
285 "develop",
286 true,
287 )
288 .expect("add");
289 let after = list(root).expect("list after");
290 assert_eq!(after.len(), 2);
291 assert!(after.iter().any(|w| w.branch.as_deref() == Some("develop")));
292 assert!(
293 after
294 .iter()
295 .any(|w| w.branch.as_deref() == Some("feature/phase-01"))
296 );
297 }
298
299 #[test]
300 fn remove_deletes_the_worktree() {
301 let repo = init_repo();
302 let root = repo.path();
303 let wt = phase_path(root, PhaseId::new(2));
304 add(root, &wt, "feature/phase-02", "develop", true).expect("add");
305 assert!(wt.exists());
306
307 remove(root, &wt, false).expect("remove");
308 assert!(!wt.exists());
309 let listing = list(root).expect("list");
310 assert!(!listing.iter().any(|w| w.path == wt));
311 }
312
313 #[test]
314 fn add_existing_branch_without_creating() {
315 let repo = init_repo();
316 let root = repo.path();
317 git(root, &["branch", "topic"]);
319 let wt = worktrees_dir(root).join("topic-wt");
320 add(root, &wt, "topic", "", false).expect("add existing branch");
321 let listing = list(root).expect("list");
322 assert!(listing.iter().any(|w| w.branch.as_deref() == Some("topic")));
323 }
324
325 #[test]
326 fn parse_porcelain_handles_detached_and_trailing_record() {
327 let text = "worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n\
328 \nworktree /repo/.worktrees/phase-07\nHEAD def456\ndetached\n";
329 let parsed = parse_porcelain(text);
330 assert_eq!(parsed.len(), 2);
331 assert_eq!(parsed[0].path, PathBuf::from("/repo"));
332 assert_eq!(parsed[0].branch.as_deref(), Some("develop"));
333 assert_eq!(parsed[0].head, "abc123");
334 assert_eq!(parsed[1].path, PathBuf::from("/repo/.worktrees/phase-07"));
335 assert_eq!(parsed[1].branch, None);
336 assert_eq!(parsed[1].head, "def456");
337 }
338
339 #[test]
340 fn prune_succeeds_on_clean_repo() {
341 let repo = init_repo();
342 prune(repo.path()).expect("prune");
343 }
344
345 #[test]
382 fn list_resolves_caller_root_under_a_hostile_git_dir() {
383 let repo = init_repo();
384 let root = repo.path();
385 let wt_path = phase_path(root, PhaseId::new(9));
386 let wt_str = wt_path.to_string_lossy();
387 assert!(
392 crate::git::git_command(root)
393 .args([
394 "worktree",
395 "add",
396 "-b",
397 "feature/phase-09",
398 &wt_str,
399 "develop",
400 ])
401 .output()
402 .unwrap()
403 .status
404 .success(),
405 "git worktree add fixture setup failed"
406 );
407
408 let cmd = crate::git::git_command(root);
410 assert!(
411 cmd.get_envs()
412 .any(|(key, value)| key == "GIT_DIR" && value.is_none()),
413 "list's own Command must mark GIT_DIR for removal"
414 );
415
416 let entries = list(root).expect("list must succeed");
418 let canonical_root = std::fs::canonicalize(root).expect("canonicalize root");
419 assert!(
420 entries.len() >= 2,
421 "expected at least main + added worktree, got: {entries:?}"
422 );
423 for entry in &entries {
424 let canonical_entry =
425 std::fs::canonicalize(&entry.path).expect("canonicalize entry path");
426 assert!(
427 canonical_entry.starts_with(&canonical_root),
428 "worktree entry {canonical_entry:?} must be under real_root {canonical_root:?}"
429 );
430 }
431 assert!(
432 entries
433 .iter()
434 .any(|w| w.branch.as_deref() == Some("feature/phase-09")),
435 "list must include the added worktree, got: {entries:?}"
436 );
437 }
438}