1#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
4#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
5#![allow(clippy::multiple_crate_versions)]
6
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use git2::Repository;
11
12use crate::error::GitError;
13use crate::repo::get_repo_root;
14
15#[derive(Debug, Clone)]
17pub struct WorktreeInfo {
18 pub path: PathBuf,
20 pub is_main: bool,
22 pub branch: Option<String>,
24 pub commit: Option<String>,
26}
27
28#[derive(Debug, Clone, Default)]
30pub struct WorktreeCreateOptions {
31 pub branch: Option<String>,
33 pub new_branch: Option<String>,
35 pub detach: bool,
37 pub force: bool,
39}
40
41pub fn get_worktrees(repo: &Repository) -> Result<Vec<WorktreeInfo>, GitError> {
52 log::debug!("Getting worktree list");
53
54 let mut worktrees = Vec::new();
55
56 let main_path = get_repo_root(repo)?;
58 let main_info = get_worktree_info_from_repo(repo, &main_path, true);
59 worktrees.push(main_info);
60
61 let worktree_names = repo.worktrees().map_err(GitError::WorktreeListError)?;
63
64 for name in worktree_names.iter().flatten() {
65 if let Ok(wt) = repo.find_worktree(name) {
66 let wt_path = wt.path();
67 if let Ok(wt_repo) = Repository::open(wt_path) {
69 worktrees.push(get_worktree_info_from_repo(&wt_repo, wt_path, false));
70 }
71 }
72 }
73
74 log::debug!("Found {} worktrees", worktrees.len());
75 Ok(worktrees)
76}
77
78fn get_worktree_info_from_repo(repo: &Repository, path: &Path, is_main: bool) -> WorktreeInfo {
80 let branch = repo.head().ok().and_then(|head| {
81 if head.is_branch() {
82 head.shorthand().map(String::from)
83 } else {
84 None
85 }
86 });
87
88 let commit = repo
89 .head()
90 .ok()
91 .and_then(|head| head.target().map(|oid| oid.to_string()[..8].to_string()));
92
93 WorktreeInfo {
94 path: path.to_path_buf(),
95 is_main,
96 branch,
97 commit,
98 }
99}
100
101pub fn get_main_worktree(repo: &Repository) -> Result<WorktreeInfo, GitError> {
111 let worktrees = get_worktrees(repo)?;
112 worktrees
113 .into_iter()
114 .find(|wt| wt.is_main)
115 .ok_or(GitError::NoMainWorktree)
116}
117
118pub fn create_worktree(
134 repo: &Repository,
135 path: &Path,
136 options: &WorktreeCreateOptions,
137) -> Result<(), GitError> {
138 log::info!("Creating worktree at {}", path.display());
139
140 if let Some(parent) = path.parent() {
142 std::fs::create_dir_all(parent).map_err(|_| GitError::InvalidPath(path.to_path_buf()))?;
143 }
144
145 let repo_root = get_repo_root(repo)?;
146
147 let mut args: Vec<&str> = vec!["worktree", "add"];
149
150 if options.force {
151 args.push("-f");
152 }
153
154 if options.detach {
155 args.push("--detach");
156 }
157
158 let path_str = path.to_string_lossy();
160
161 let new_branch_owned: String;
163 if let Some(ref branch_name) = options.new_branch {
164 args.push("-b");
165 new_branch_owned = branch_name.clone();
166 args.push(&new_branch_owned);
167 }
168
169 args.push(&path_str);
170
171 let branch_owned: String;
173 if let Some(ref branch_name) = options.branch {
174 branch_owned = branch_name.clone();
175 args.push(&branch_owned);
176 }
177
178 log::debug!("Running: git {}", args.join(" "));
179
180 let output = Command::new("git")
181 .args(&args)
182 .current_dir(&repo_root)
183 .output()
184 .map_err(|e| GitError::WorktreeCreateError {
185 path: path.to_path_buf(),
186 source: git2::Error::from_str(&e.to_string()),
187 })?;
188
189 if !output.status.success() {
190 let stderr = String::from_utf8_lossy(&output.stderr);
191 return Err(GitError::WorktreeCreateError {
192 path: path.to_path_buf(),
193 source: git2::Error::from_str(&stderr),
194 });
195 }
196
197 log::info!("Created worktree at {}", path.display());
198 Ok(())
199}
200
201pub fn prune_worktrees(repo: &Repository) -> Result<(), GitError> {
213 let repo_root = get_repo_root(repo)?;
214
215 log::info!("Pruning stale worktrees");
216
217 let output = Command::new("git")
218 .args(["worktree", "prune"])
219 .current_dir(&repo_root)
220 .output()
221 .map_err(|e| GitError::WorktreePruneError(git2::Error::from_str(&e.to_string())))?;
222
223 if !output.status.success() {
224 let stderr = String::from_utf8_lossy(&output.stderr);
225 return Err(GitError::WorktreePruneError(git2::Error::from_str(&stderr)));
226 }
227
228 log::info!("Pruned stale worktrees");
229 Ok(())
230}
231
232pub fn remove_worktree(
249 repo: &Repository,
250 worktree_path: &Path,
251 force: bool,
252) -> Result<(), GitError> {
253 let repo_root = get_repo_root(repo)?;
255 let main_canonical = repo_root
256 .canonicalize()
257 .map_err(|_| GitError::CannotRemoveMainWorktree(repo_root.to_string_lossy().to_string()))?;
258 if let Ok(target_canonical) = worktree_path.canonicalize()
259 && target_canonical == main_canonical
260 {
261 return Err(GitError::CannotRemoveMainWorktree(
262 worktree_path.to_string_lossy().to_string(),
263 ));
264 }
265
266 log::info!("Removing worktree at {}", worktree_path.display());
267
268 let mut args: Vec<&str> = vec!["worktree", "remove"];
269
270 if force {
271 args.push("--force");
272 }
273
274 let path_str = worktree_path.to_string_lossy();
275 args.push(&path_str);
276
277 log::debug!("Running: git {}", args.join(" "));
278
279 let output = Command::new("git")
280 .args(&args)
281 .current_dir(&repo_root)
282 .output()
283 .map_err(|e| GitError::WorktreeRemoveError {
284 path: worktree_path.to_string_lossy().to_string(),
285 message: e.to_string(),
286 })?;
287
288 if !output.status.success() {
289 let stderr = String::from_utf8_lossy(&output.stderr);
290 return Err(GitError::WorktreeRemoveError {
291 path: worktree_path.to_string_lossy().to_string(),
292 message: stderr.trim().to_string(),
293 });
294 }
295
296 log::info!("Removed worktree at {}", worktree_path.display());
297 Ok(())
298}
299
300pub fn delete_branch(repo: &Repository, branch: &str, force: bool) -> Result<(), GitError> {
314 let repo_root = get_repo_root(repo)?;
315 let flag = if force { "-D" } else { "-d" };
316
317 log::info!("Deleting branch '{branch}' (force={force})");
318 log::debug!("Running: git branch {flag} {branch}");
319
320 let output = Command::new("git")
321 .args(["branch", flag, branch])
322 .current_dir(&repo_root)
323 .output()
324 .map_err(|e| GitError::BranchDeleteError {
325 branch: branch.to_string(),
326 message: e.to_string(),
327 })?;
328
329 if !output.status.success() {
330 let stderr = String::from_utf8_lossy(&output.stderr);
331 return Err(GitError::BranchDeleteError {
332 branch: branch.to_string(),
333 message: stderr.trim().to_string(),
334 });
335 }
336
337 log::info!("Deleted branch '{branch}'");
338 Ok(())
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use tempfile::TempDir;
345
346 fn create_test_repo() -> (TempDir, Repository) {
347 let dir = TempDir::new().unwrap();
348
349 Command::new("git")
350 .args(["init"])
351 .current_dir(dir.path())
352 .output()
353 .unwrap();
354
355 Command::new("git")
356 .args(["config", "user.email", "test@test.com"])
357 .current_dir(dir.path())
358 .output()
359 .unwrap();
360
361 Command::new("git")
362 .args(["config", "user.name", "Test"])
363 .current_dir(dir.path())
364 .output()
365 .unwrap();
366
367 std::fs::write(dir.path().join("README.md"), "# Test").unwrap();
368 Command::new("git")
369 .args(["add", "."])
370 .current_dir(dir.path())
371 .output()
372 .unwrap();
373 Command::new("git")
374 .args(["commit", "-m", "Initial commit"])
375 .current_dir(dir.path())
376 .output()
377 .unwrap();
378
379 let repo = Repository::open(dir.path()).unwrap();
380 (dir, repo)
381 }
382
383 #[test]
384 fn test_get_worktrees() {
385 let (_dir, repo) = create_test_repo();
386 let worktrees = get_worktrees(&repo).unwrap();
387 assert_eq!(worktrees.len(), 1);
388 assert!(worktrees[0].is_main);
389 }
390
391 #[test]
392 fn test_get_main_worktree() {
393 let (dir, repo) = create_test_repo();
394 let main = get_main_worktree(&repo).unwrap();
395 assert!(main.is_main);
396 let expected = dir.path().canonicalize().unwrap();
398 let actual = main.path.canonicalize().unwrap();
399 assert_eq!(actual, expected);
400 }
401
402 #[test]
403 fn test_get_worktrees_finds_linked() {
404 let (dir, repo) = create_test_repo();
405
406 let wt_path = dir.path().join("linked-wt");
408 Command::new("git")
409 .args(["worktree", "add", "-b", "linked-branch"])
410 .arg(&wt_path)
411 .current_dir(dir.path())
412 .output()
413 .unwrap();
414
415 let worktrees = get_worktrees(&repo).unwrap();
416
417 assert_eq!(worktrees.len(), 2, "should find main + linked worktree");
418
419 let main = worktrees.iter().find(|w| w.is_main).unwrap();
420 let linked = worktrees.iter().find(|w| !w.is_main).unwrap();
421
422 let expected_main = dir.path().canonicalize().unwrap();
424 let actual_main = main.path.canonicalize().unwrap();
425 assert_eq!(actual_main, expected_main);
426
427 let expected_linked = wt_path.canonicalize().unwrap();
429 let actual_linked = linked.path.canonicalize().unwrap();
430 assert_eq!(actual_linked, expected_linked);
431 assert_eq!(linked.branch.as_deref(), Some("linked-branch"));
432
433 Command::new("git")
435 .args(["worktree", "remove", "--force"])
436 .arg(&wt_path)
437 .current_dir(dir.path())
438 .output()
439 .unwrap();
440 }
441
442 #[test]
443 fn test_remove_worktree() {
444 let (dir, repo) = create_test_repo();
445
446 let wt_path = dir.path().join("to-remove");
448 Command::new("git")
449 .args(["worktree", "add", "-b", "remove-branch"])
450 .arg(&wt_path)
451 .current_dir(dir.path())
452 .output()
453 .unwrap();
454
455 assert!(wt_path.exists(), "worktree dir should exist before removal");
456 assert_eq!(get_worktrees(&repo).unwrap().len(), 2);
457
458 remove_worktree(&repo, &wt_path, false).unwrap();
460
461 assert!(
462 !wt_path.exists(),
463 "worktree dir should be gone after removal"
464 );
465
466 let repo = Repository::open(dir.path()).unwrap();
468 let worktrees = get_worktrees(&repo).unwrap();
469 assert_eq!(worktrees.len(), 1, "only main worktree should remain");
470 assert!(worktrees[0].is_main);
471 }
472
473 #[test]
474 fn test_remove_worktree_force() {
475 let (dir, repo) = create_test_repo();
476
477 let wt_path = dir.path().join("dirty-wt");
479 Command::new("git")
480 .args(["worktree", "add", "-b", "dirty-branch"])
481 .arg(&wt_path)
482 .current_dir(dir.path())
483 .output()
484 .unwrap();
485
486 std::fs::write(wt_path.join("dirty-file.txt"), "uncommitted").unwrap();
488 Command::new("git")
489 .args(["add", "dirty-file.txt"])
490 .current_dir(&wt_path)
491 .output()
492 .unwrap();
493
494 let result = remove_worktree(&repo, &wt_path, false);
496 assert!(
497 result.is_err(),
498 "non-force removal of dirty worktree should fail"
499 );
500
501 remove_worktree(&repo, &wt_path, true).unwrap();
503 assert!(
504 !wt_path.exists(),
505 "worktree should be gone after force removal"
506 );
507 }
508
509 #[test]
510 fn test_remove_main_worktree_rejected() {
511 let (dir, repo) = create_test_repo();
512
513 let result = remove_worktree(&repo, dir.path(), false);
514 assert!(result.is_err(), "removing main worktree should fail");
515
516 match result.unwrap_err() {
517 GitError::CannotRemoveMainWorktree(_) => {}
518 other => panic!("expected CannotRemoveMainWorktree, got: {other}"),
519 }
520 }
521
522 #[test]
523 fn test_delete_branch() {
524 let (dir, repo) = create_test_repo();
525
526 Command::new("git")
528 .args(["branch", "to-delete"])
529 .current_dir(dir.path())
530 .output()
531 .unwrap();
532
533 let output = Command::new("git")
535 .args(["branch", "--list", "to-delete"])
536 .current_dir(dir.path())
537 .output()
538 .unwrap();
539 let stdout = String::from_utf8_lossy(&output.stdout);
540 assert!(
541 stdout.contains("to-delete"),
542 "branch should exist before delete"
543 );
544
545 delete_branch(&repo, "to-delete", false).unwrap();
547
548 let output = Command::new("git")
550 .args(["branch", "--list", "to-delete"])
551 .current_dir(dir.path())
552 .output()
553 .unwrap();
554 let stdout = String::from_utf8_lossy(&output.stdout);
555 assert!(
556 !stdout.contains("to-delete"),
557 "branch should be gone after delete"
558 );
559 }
560
561 #[test]
562 fn test_delete_branch_force() {
563 let (dir, repo) = create_test_repo();
564
565 let wt_path = dir.path().join("unmerged-wt");
568 Command::new("git")
569 .args(["worktree", "add", "-b", "unmerged-branch"])
570 .arg(&wt_path)
571 .current_dir(dir.path())
572 .output()
573 .unwrap();
574
575 std::fs::write(wt_path.join("new-file.txt"), "content").unwrap();
577 Command::new("git")
578 .args(["add", "new-file.txt"])
579 .current_dir(&wt_path)
580 .output()
581 .unwrap();
582 Command::new("git")
583 .args(["commit", "-m", "unmerged commit"])
584 .current_dir(&wt_path)
585 .output()
586 .unwrap();
587
588 remove_worktree(&repo, &wt_path, true).unwrap();
590
591 let result = delete_branch(&repo, "unmerged-branch", false);
593 assert!(
594 result.is_err(),
595 "safe delete of unmerged branch should fail"
596 );
597
598 delete_branch(&repo, "unmerged-branch", true).unwrap();
600
601 let output = Command::new("git")
603 .args(["branch", "--list", "unmerged-branch"])
604 .current_dir(dir.path())
605 .output()
606 .unwrap();
607 let stdout = String::from_utf8_lossy(&output.stdout);
608 assert!(
609 !stdout.contains("unmerged-branch"),
610 "branch should be gone after force delete"
611 );
612 }
613
614 #[test]
615 fn test_delete_nonexistent_branch_fails() {
616 let (_dir, repo) = create_test_repo();
617
618 let result = delete_branch(&repo, "does-not-exist", false);
619 assert!(result.is_err(), "deleting nonexistent branch should fail");
620
621 match result.unwrap_err() {
622 GitError::BranchDeleteError { branch, .. } => {
623 assert_eq!(branch, "does-not-exist");
624 }
625 other => panic!("expected BranchDeleteError, got: {other}"),
626 }
627 }
628}