Skip to main content

worktree_setup_git/
worktree.rs

1//! Worktree operations.
2
3#![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/// Information about a git worktree.
16#[derive(Debug, Clone)]
17pub struct WorktreeInfo {
18    /// Filesystem path to the worktree.
19    pub path: PathBuf,
20    /// Whether this is the main worktree.
21    pub is_main: bool,
22    /// Branch name if not detached.
23    pub branch: Option<String>,
24    /// Current commit hash.
25    pub commit: Option<String>,
26}
27
28/// Options for creating a new worktree.
29#[derive(Debug, Clone, Default)]
30pub struct WorktreeCreateOptions {
31    /// Branch to check out (existing branch).
32    pub branch: Option<String>,
33    /// Create and check out a new branch.
34    pub new_branch: Option<String>,
35    /// Create worktree with detached HEAD.
36    pub detach: bool,
37    /// Force creation even if the path is already registered as a worktree.
38    pub force: bool,
39}
40
41/// Get a list of all worktrees for a repository.
42///
43/// # Arguments
44///
45/// * `repo` - The repository
46///
47/// # Errors
48///
49/// * If the worktree list cannot be retrieved
50/// * If worktree metadata cannot be read
51pub fn get_worktrees(repo: &Repository) -> Result<Vec<WorktreeInfo>, GitError> {
52    log::debug!("Getting worktree list");
53
54    let mut worktrees = Vec::new();
55
56    // First, add the main worktree
57    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    // Then add linked worktrees
62    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            // Open the worktree as a repo to get branch info
68            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
78/// Get worktree info from a repository.
79fn 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
101/// Get the main worktree.
102///
103/// # Arguments
104///
105/// * `repo` - The repository
106///
107/// # Errors
108///
109/// * If no main worktree is found
110pub 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
118/// Create a new worktree using git CLI.
119///
120/// We use the CLI here because git2's worktree API has tricky lifetime requirements
121/// that make it difficult to set branch references.
122///
123/// # Arguments
124///
125/// * `repo` - The repository
126/// * `path` - Path where the worktree should be created
127/// * `options` - Creation options
128///
129/// # Errors
130///
131/// * If the worktree cannot be created
132/// * If the specified branch does not exist
133pub 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    // Ensure parent directory exists
141    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    // Build git worktree add command
148    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    // Convert path to string for the command
159    let path_str = path.to_string_lossy();
160
161    // Handle new branch
162    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    // Handle existing branch
172    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
201/// Prune stale worktree registrations using the git CLI.
202///
203/// Removes worktree entries whose directories no longer exist on disk.
204///
205/// # Arguments
206///
207/// * `repo` - The repository
208///
209/// # Errors
210///
211/// * If the prune command fails
212pub 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
232/// Remove a linked worktree using the git CLI.
233///
234/// This removes the worktree directory and its git tracking. The main
235/// worktree cannot be removed — attempting to do so returns an error.
236///
237/// # Arguments
238///
239/// * `repo` - The repository
240/// * `worktree_path` - Path to the worktree to remove
241/// * `force` - If true, pass `--force` to allow removal even with
242///   uncommitted changes or if the working tree is dirty
243///
244/// # Errors
245///
246/// * If `worktree_path` is the main worktree
247/// * If the git CLI command fails
248pub fn remove_worktree(
249    repo: &Repository,
250    worktree_path: &Path,
251    force: bool,
252) -> Result<(), GitError> {
253    // Guard: never remove the main worktree
254    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
300/// Delete a local branch using the git CLI.
301///
302/// # Arguments
303///
304/// * `repo` - The repository
305/// * `branch` - Name of the branch to delete
306/// * `force` - If true, uses `-D` (force delete) instead of `-d`
307///   (safe delete). Safe delete refuses to delete branches that have
308///   not been fully merged into their upstream or HEAD.
309///
310/// # Errors
311///
312/// * If the git CLI command fails (e.g., branch not found or not merged)
313pub 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        // Canonicalize both paths to handle macOS /var -> /private/var symlink
397        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        // Create a linked worktree
407        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        // Main worktree
423        let expected_main = dir.path().canonicalize().unwrap();
424        let actual_main = main.path.canonicalize().unwrap();
425        assert_eq!(actual_main, expected_main);
426
427        // Linked worktree
428        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        // Clean up
434        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        // Create a linked worktree
447        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 it
459        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        // Re-open repo to get fresh worktree list (git2 caches)
467        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        // Create a linked worktree
478        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        // Make it dirty (uncommitted changes)
487        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        // Non-force removal should fail on dirty worktree
495        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        // Force removal should succeed
502        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        // Create a branch (merged into current HEAD, so -d will work)
527        Command::new("git")
528            .args(["branch", "to-delete"])
529            .current_dir(dir.path())
530            .output()
531            .unwrap();
532
533        // Verify branch exists
534        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 it (safe mode)
546        delete_branch(&repo, "to-delete", false).unwrap();
547
548        // Verify branch is gone
549        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        // Create a worktree with a new branch, add a commit, then remove the worktree.
566        // The branch will be "unmerged" relative to the main branch.
567        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        // Add a commit on the unmerged branch
576        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 the worktree (force because it has extra commit)
589        remove_worktree(&repo, &wt_path, true).unwrap();
590
591        // Safe delete should fail (branch is not merged)
592        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        // Force delete should succeed
599        delete_branch(&repo, "unmerged-branch", true).unwrap();
600
601        // Verify branch is gone
602        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}