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}