Skip to main content

workon/
move.rs

1//! Atomic worktree and branch renaming.
2//!
3//! This module provides atomic renaming of worktrees and their associated branches,
4//! keeping the branch name and directory structure synchronized.
5//!
6//! ## Atomic Operation Strategy
7//!
8//! The move operation consists of three steps:
9//! 1. Rename the branch using `git branch -m`
10//! 2. Move the worktree directory to match the new branch name
11//! 3. Update git worktree metadata bidirectionally:
12//!    - Update `.git/worktrees/<name>/gitdir` to point to new location
13//!    - Update worktree's `.git` file to point to correct admin directory
14//!
15//! If the directory move fails after branch rename, the operation rolls back the branch
16//! rename to maintain consistency.
17//!
18//! ## Safety Checks
19//!
20//! By default, the operation performs several safety checks:
21//! - Source worktree exists
22//! - Target doesn't exist (no conflicts with existing worktrees or branches)
23//! - Source is not detached HEAD (can't rename detached HEAD)
24//! - Source is not protected (matches `workon.pruneProtectedBranches`)
25//! - Source is not dirty (no uncommitted changes)
26//! - Source has no unpushed commits (all commits are pushed to remote)
27//!
28//! The `--force` flag overrides all safety checks (single flag for simplicity).
29//!
30//! ## Namespace Support
31//!
32//! Supports moving worktrees between namespaces:
33//! ```bash
34//! git workon move feature user/feature        # Move into namespace
35//! git workon move user/feature feature        # Move out of namespace
36//! git workon move old/path new/deeper/path    # Reorganize
37//! ```
38//!
39//! Parent directories are created automatically as needed.
40//!
41//! ## CLI Modes
42//!
43//! Two invocation modes:
44//! 1. **Single-arg mode**: `git workon move <new-name>` - Renames current worktree (when run from within a worktree)
45//! 2. **Two-arg mode**: `git workon move <from> <to>` - Explicit source and target
46//!
47//! ## Example Usage
48//!
49//! ```bash
50//! # Rename current worktree
51//! cd ~/repos/project/feature
52//! git workon move new-feature-name
53//!
54//! # Rename specific worktree
55//! git workon move old-name new-name
56//!
57//! # Move into namespace
58//! git workon move feature user/feature
59//!
60//! # Preview changes
61//! git workon move --dry-run old new
62//!
63//! # Override safety checks
64//! git workon move --force dirty-branch new-name
65//! ```
66
67use git2::BranchType;
68use std::{fs, path::Path};
69
70use crate::{
71    error::Result, find_worktree, get_worktrees, WorkonConfig, WorkonError, WorktreeDescriptor,
72    WorktreeError,
73};
74
75/// Options for moving a worktree
76#[derive(Default)]
77pub struct MoveOptions {
78    /// Override safety checks (dirty, unpushed, protected)
79    pub force: bool,
80}
81
82/// Move (rename) a worktree and its branch atomically.
83///
84/// This performs the following operations:
85/// 1. Renames the branch
86/// 2. Moves the worktree directory
87/// 3. Updates worktree metadata
88///
89/// The operation includes rollback if the directory move fails after branch rename.
90///
91/// # Arguments
92///
93/// * `repo` - The repository containing the worktree
94/// * `from` - Current worktree/branch name
95/// * `to` - New worktree/branch name
96/// * `options` - Move options (force flag, etc.)
97///
98/// # Errors
99///
100/// Returns an error if:
101/// - Source worktree doesn't exist
102/// - Target already exists (worktree or branch)
103/// - Source is detached HEAD
104/// - Source is protected (unless force)
105/// - Source is dirty (unless force)
106/// - Source has unpushed commits (unless force)
107/// - Directory move fails
108pub fn move_worktree(
109    repo: &git2::Repository,
110    from: &str,
111    to: &str,
112    options: &MoveOptions,
113) -> Result<WorktreeDescriptor> {
114    // Find source worktree
115    let source = find_worktree(repo, from)?;
116
117    // Validate the move
118    validate_move(repo, &source, to, options)?;
119
120    // Execute the move
121    let root = crate::workon_root(repo)?;
122    let branch_name = source.branch()?.unwrap();
123    let old_path = source.path().to_path_buf();
124    let new_path = root.join(to);
125
126    // Calculate worktree names (basename of branch names)
127    let old_name = source.name().unwrap().to_string();
128    let new_name = Path::new(to)
129        .file_name()
130        .and_then(|s| s.to_str())
131        .ok_or(WorktreeError::InvalidName)?
132        .to_string();
133
134    // Create parent directories for namespace changes
135    if let Some(parent) = new_path.parent() {
136        std::fs::create_dir_all(parent)?;
137    }
138
139    // Step 1: Rename the branch
140    let mut branch = repo.find_branch(&branch_name, BranchType::Local)?;
141    branch.rename(to, false)?;
142
143    // Step 2: Move the directory (with rollback on failure)
144    if let Err(e) = fs::rename(&old_path, &new_path) {
145        // Attempt to rollback branch rename
146        let _ = branch.rename(&branch_name, false);
147        return Err(WorkonError::Io(e));
148    }
149
150    // Step 3: Rename worktree metadata directory if name changed
151    let old_meta_dir = repo.path().join("worktrees").join(&old_name);
152    let new_meta_dir = repo.path().join("worktrees").join(&new_name);
153    if old_meta_dir != new_meta_dir && old_meta_dir.exists() {
154        fs::rename(&old_meta_dir, &new_meta_dir)?;
155    }
156    if new_meta_dir.exists() {
157        let new_gitdir = new_meta_dir.join("gitdir");
158        let new_git = new_path.join(".git");
159
160        fs::write(&new_gitdir, format!("{}\n", new_git.display()))?;
161        fs::write(&new_git, format!("gitdir: {}\n", new_meta_dir.display()))?;
162    }
163
164    WorktreeDescriptor::new(repo, &new_name)
165}
166
167/// Validate that a move from `source` to `target_name` is safe to perform.
168///
169/// Checks (unless `options.force` is set):
170/// - Source is not detached HEAD
171/// - Target does not already exist as a worktree or branch
172/// - Source is not a protected branch
173/// - Source has no uncommitted changes
174/// - Source has no unpushed commits
175pub fn validate_move(
176    repo: &git2::Repository,
177    source: &WorktreeDescriptor,
178    target_name: &str,
179    options: &MoveOptions,
180) -> Result<()> {
181    // 1. Check if source is detached
182    if source.is_detached()? {
183        return Err(WorktreeError::CannotMoveDetached.into());
184    }
185
186    // 2. Check if target already exists (worktree name or branch name)
187    for wt in get_worktrees(repo)? {
188        if wt.name() == Some(target_name)
189            || wt.branch().ok().flatten().as_deref() == Some(target_name)
190        {
191            return Err(WorktreeError::TargetExists {
192                to: target_name.to_string(),
193            }
194            .into());
195        }
196    }
197
198    // 3. Check if branch exists with target name
199    if repo.find_branch(target_name, BranchType::Local).is_ok() {
200        return Err(WorktreeError::TargetExists {
201            to: target_name.to_string(),
202        }
203        .into());
204    }
205
206    // 4. Check if source is protected (unless --force)
207    if !options.force {
208        let config = WorkonConfig::new(repo)?;
209        let branch_name = source.branch()?.unwrap();
210        if config.is_protected(&branch_name) {
211            return Err(WorktreeError::ProtectedBranchMove(branch_name).into());
212        }
213    }
214
215    // 5. Check if dirty (unless --force)
216    if !options.force && source.is_dirty()? {
217        return Err(WorktreeError::DirtyWorktree.into());
218    }
219
220    // 6. Check if unpushed (unless --force)
221    if !options.force && source.has_unpushed_commits()? {
222        return Err(WorktreeError::UnpushedCommits.into());
223    }
224
225    Ok(())
226}