1use crate::git::git_command;
8use std::path::{Path, PathBuf};
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 = git_command(project_root)
122 .args(["worktree", "list", "--porcelain"])
123 .output()?;
124 if !output.status.success() {
125 return Err(WorktreeError::Command(stderr_or_status(&output)));
126 }
127 Ok(parse_porcelain(&String::from_utf8_lossy(&output.stdout)))
128}
129
130fn parse_porcelain(text: &str) -> Vec<WorktreeInfo> {
136 let mut result = Vec::new();
137 let mut path: Option<PathBuf> = None;
138 let mut head = String::new();
139 let mut branch: Option<String> = None;
140
141 let mut flush = |path: &mut Option<PathBuf>, head: &mut String, branch: &mut Option<String>| {
142 if let Some(p) = path.take() {
143 result.push(WorktreeInfo {
144 path: p,
145 branch: branch.take(),
146 head: std::mem::take(head),
147 });
148 } else {
149 *head = String::new();
150 *branch = None;
151 }
152 };
153
154 for line in text.lines() {
155 if line.is_empty() {
156 flush(&mut path, &mut head, &mut branch);
157 continue;
158 }
159 if let Some(p) = line.strip_prefix("worktree ") {
160 path = Some(PathBuf::from(p));
161 } else if let Some(h) = line.strip_prefix("HEAD ") {
162 head = h.to_string();
163 } else if let Some(b) = line.strip_prefix("branch ") {
164 branch = Some(b.trim_start_matches("refs/heads/").to_string());
165 }
166 }
168 flush(&mut path, &mut head, &mut branch);
170 result
171}
172
173fn run(project_root: &Path, args: &[&str]) -> Result<(), WorktreeError> {
174 let output = git_command(project_root).args(args).output()?;
175 if output.status.success() {
176 Ok(())
177 } else {
178 Err(WorktreeError::Command(stderr_or_status(&output)))
179 }
180}
181
182fn stderr_or_status(output: &std::process::Output) -> String {
183 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
184 if stderr.is_empty() {
185 format!("exited with {}", output.status)
186 } else {
187 stderr
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use tempfile::TempDir;
195
196 fn git(root: &Path, args: &[&str]) {
197 let output = crate::test_support::git_command(root)
198 .args(args)
199 .output()
200 .expect("spawn git");
201 assert!(
202 output.status.success(),
203 "git {args:?} failed: {}",
204 String::from_utf8_lossy(&output.stderr)
205 );
206 }
207
208 fn init_repo() -> TempDir {
210 let dir = tempfile::tempdir().unwrap();
211 let root = dir.path();
212 git(root, &["init", "-q"]);
213 git(root, &["config", "user.email", "test@example.com"]);
214 git(root, &["config", "user.name", "Test"]);
215 git(root, &["config", "commit.gpgsign", "false"]);
216 git(root, &["config", "core.hooksPath", "/dev/null"]);
217 std::fs::write(root.join("README.md"), "base\n").unwrap();
218 git(root, &["add", "."]);
219 git(root, &["commit", "-q", "-m", "base"]);
220 git(root, &["branch", "-M", "main"]);
221 git(root, &["checkout", "-q", "-b", "develop"]);
222 dir
223 }
224
225 #[test]
226 fn path_helpers_format_phase_numbers() {
227 let root = Path::new("/repo");
228 assert_eq!(worktrees_dir(root), Path::new("/repo/.worktrees"));
229 assert_eq!(phase_path(root, 7), Path::new("/repo/.worktrees/phase-07"));
230 assert_eq!(
231 phase_agent_path(root, 7, "claude"),
232 Path::new("/repo/.worktrees/phase-07-claude")
233 );
234 assert_eq!(
235 reference_path(root),
236 Path::new("/repo/.worktrees/reference")
237 );
238 }
239
240 #[test]
241 fn add_creates_worktree_on_new_branch() {
242 let repo = init_repo();
243 let root = repo.path();
244 let wt = phase_path(root, 7);
245
246 add(root, &wt, "feature/phase-07", "develop", true).expect("add");
247
248 assert!(wt.exists());
249 assert!(wt.join("README.md").exists());
250
251 let listing = list(root).expect("list");
252 let entry = listing
253 .iter()
254 .find(|w| w.path.ends_with("phase-07") || w.path == wt)
255 .expect("phase-07 worktree present");
256 assert_eq!(entry.branch.as_deref(), Some("feature/phase-07"));
257 }
258
259 #[test]
260 fn add_errors_when_path_exists() {
261 let repo = init_repo();
262 let root = repo.path();
263 let wt = phase_path(root, 7);
264 add(root, &wt, "feature/phase-07", "develop", true).expect("add");
265
266 let err = add(root, &wt, "feature/phase-07b", "develop", true).unwrap_err();
267 assert!(matches!(err, WorktreeError::Exists(_)));
268 }
269
270 #[test]
271 fn list_includes_main_and_added_worktrees() {
272 let repo = init_repo();
273 let root = repo.path();
274 let before = list(root).expect("list before");
275 assert_eq!(before.len(), 1, "only the main worktree initially");
276
277 add(
278 root,
279 &phase_path(root, 1),
280 "feature/phase-01",
281 "develop",
282 true,
283 )
284 .expect("add");
285 let after = list(root).expect("list after");
286 assert_eq!(after.len(), 2);
287 assert!(after.iter().any(|w| w.branch.as_deref() == Some("develop")));
288 assert!(
289 after
290 .iter()
291 .any(|w| w.branch.as_deref() == Some("feature/phase-01"))
292 );
293 }
294
295 #[test]
296 fn remove_deletes_the_worktree() {
297 let repo = init_repo();
298 let root = repo.path();
299 let wt = phase_path(root, 2);
300 add(root, &wt, "feature/phase-02", "develop", true).expect("add");
301 assert!(wt.exists());
302
303 remove(root, &wt, false).expect("remove");
304 assert!(!wt.exists());
305 let listing = list(root).expect("list");
306 assert!(!listing.iter().any(|w| w.path == wt));
307 }
308
309 #[test]
310 fn add_existing_branch_without_creating() {
311 let repo = init_repo();
312 let root = repo.path();
313 git(root, &["branch", "topic"]);
315 let wt = worktrees_dir(root).join("topic-wt");
316 add(root, &wt, "topic", "", false).expect("add existing branch");
317 let listing = list(root).expect("list");
318 assert!(listing.iter().any(|w| w.branch.as_deref() == Some("topic")));
319 }
320
321 #[test]
322 fn parse_porcelain_handles_detached_and_trailing_record() {
323 let text = "worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n\
324 \nworktree /repo/.worktrees/phase-07\nHEAD def456\ndetached\n";
325 let parsed = parse_porcelain(text);
326 assert_eq!(parsed.len(), 2);
327 assert_eq!(parsed[0].path, PathBuf::from("/repo"));
328 assert_eq!(parsed[0].branch.as_deref(), Some("develop"));
329 assert_eq!(parsed[0].head, "abc123");
330 assert_eq!(parsed[1].path, PathBuf::from("/repo/.worktrees/phase-07"));
331 assert_eq!(parsed[1].branch, None);
332 assert_eq!(parsed[1].head, "def456");
333 }
334
335 #[test]
336 fn prune_succeeds_on_clean_repo() {
337 let repo = init_repo();
338 prune(repo.path()).expect("prune");
339 }
340
341 #[test]
378 fn list_resolves_caller_root_under_a_hostile_git_dir() {
379 let repo = init_repo();
380 let root = repo.path();
381 let wt_path = phase_path(root, 9);
382 let wt_str = wt_path.to_string_lossy();
383 assert!(
388 crate::git::git_command(root)
389 .args([
390 "worktree",
391 "add",
392 "-b",
393 "feature/phase-09",
394 &wt_str,
395 "develop",
396 ])
397 .output()
398 .unwrap()
399 .status
400 .success(),
401 "git worktree add fixture setup failed"
402 );
403
404 let cmd = crate::git::git_command(root);
406 assert!(
407 cmd.get_envs()
408 .any(|(key, value)| key == "GIT_DIR" && value.is_none()),
409 "list's own Command must mark GIT_DIR for removal"
410 );
411
412 let entries = list(root).expect("list must succeed");
414 let canonical_root = std::fs::canonicalize(root).expect("canonicalize root");
415 assert!(
416 entries.len() >= 2,
417 "expected at least main + added worktree, got: {entries:?}"
418 );
419 for entry in &entries {
420 let canonical_entry =
421 std::fs::canonicalize(&entry.path).expect("canonicalize entry path");
422 assert!(
423 canonical_entry.starts_with(&canonical_root),
424 "worktree entry {canonical_entry:?} must be under real_root {canonical_root:?}"
425 );
426 }
427 assert!(
428 entries
429 .iter()
430 .any(|w| w.branch.as_deref() == Some("feature/phase-09")),
431 "list must include the added worktree, got: {entries:?}"
432 );
433 }
434}