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 encode_worktree_name, error::Result, find_worktree, get_worktrees, WorkonConfig, WorkonError,
72 WorktreeDescriptor, 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 // `old_name` is read back from git, never recomputed: git worktree move does not
127 // rename the admin directory, so a worktree moved outside this function may already
128 // carry a stale (or legacy basename) name. `new_name` is the one place this move
129 // computes a fresh name, encoding the target's root-relative path (see ADR-027).
130 let old_name = source.name().unwrap().to_string();
131 let new_name = encode_worktree_name(to);
132
133 // Create parent directories for namespace changes
134 if let Some(parent) = new_path.parent() {
135 std::fs::create_dir_all(parent)?;
136 }
137
138 // Step 1: Rename the branch
139 let mut branch = repo.find_branch(&branch_name, BranchType::Local)?;
140 branch.rename(to, false)?;
141
142 // Step 2: Move the directory (with rollback on failure)
143 if let Err(e) = fs::rename(&old_path, &new_path) {
144 // Attempt to rollback branch rename
145 let _ = branch.rename(&branch_name, false);
146 return Err(WorkonError::Io(e));
147 }
148
149 // Step 3: Rename the worktree metadata directory and rewrite the gitdir/.git pointer
150 // pair so the admin directory and the moved worktree stay linked. No rollback here
151 // (see ADR-027) — on failure the caller reports the error and points at
152 // `workon doctor --fix`, which repairs the same pointer pair.
153 rename_worktree_metadata(repo, &old_name, &new_name, &new_path)?;
154
155 WorktreeDescriptor::new(repo, &new_name)
156}
157
158/// Rename a worktree's admin (metadata) directory from `old_name` to `new_name`, and
159/// rewrite the `gitdir` / `.git` pointer pair so `worktree_path` and the admin directory
160/// stay linked.
161///
162/// This is the metadata-only tail of [`move_worktree`]'s three-step move, factored out so
163/// `workon doctor --fix` can repair a stale admin name (one that no longer matches
164/// [`encode_worktree_name`] of the worktree's current path) without moving the worktree
165/// directory itself.
166pub fn rename_worktree_metadata(
167 repo: &git2::Repository,
168 old_name: &str,
169 new_name: &str,
170 worktree_path: &Path,
171) -> Result<()> {
172 let old_meta_dir = repo.path().join("worktrees").join(old_name);
173 let new_meta_dir = repo.path().join("worktrees").join(new_name);
174 if old_meta_dir != new_meta_dir && old_meta_dir.exists() {
175 fs::rename(&old_meta_dir, &new_meta_dir)?;
176 }
177 if new_meta_dir.exists() {
178 let new_gitdir = new_meta_dir.join("gitdir");
179 let new_git = worktree_path.join(".git");
180
181 fs::write(&new_gitdir, format!("{}\n", new_git.display()))?;
182 fs::write(&new_git, format!("gitdir: {}\n", new_meta_dir.display()))?;
183 }
184 Ok(())
185}
186
187/// Validate that a move from `source` to `target_name` is safe to perform.
188///
189/// Checks (unless `options.force` is set):
190/// - Source is not detached HEAD
191/// - Target does not already exist as a worktree or branch
192/// - Source is not a protected branch
193/// - Source has no uncommitted changes
194/// - Source has no unpushed commits
195pub fn validate_move(
196 repo: &git2::Repository,
197 source: &WorktreeDescriptor,
198 target_name: &str,
199 options: &MoveOptions,
200) -> Result<()> {
201 // 1. Check if source is detached
202 if source.is_detached()? {
203 return Err(WorktreeError::CannotMoveDetached.into());
204 }
205
206 // 2. Check if target already exists (worktree name or branch name)
207 for wt in get_worktrees(repo)? {
208 if wt.name() == Some(target_name)
209 || wt.branch().ok().flatten().as_deref() == Some(target_name)
210 {
211 return Err(WorktreeError::TargetExists {
212 to: target_name.to_string(),
213 }
214 .into());
215 }
216 }
217
218 // 3. Check if branch exists with target name
219 if repo.find_branch(target_name, BranchType::Local).is_ok() {
220 return Err(WorktreeError::TargetExists {
221 to: target_name.to_string(),
222 }
223 .into());
224 }
225
226 // 4. Check if source is protected (unless --force)
227 if !options.force {
228 let config = WorkonConfig::new(repo)?;
229 let branch_name = source.branch()?.unwrap();
230 if config.is_protected(&branch_name) {
231 return Err(WorktreeError::ProtectedBranchMove(branch_name).into());
232 }
233 }
234
235 // 5. Check if dirty (unless --force)
236 if !options.force && source.is_dirty()? {
237 return Err(WorktreeError::DirtyWorktree.into());
238 }
239
240 // 6. Check if unpushed (unless --force)
241 if !options.force && source.has_unpushed_commits()? {
242 return Err(WorktreeError::UnpushedCommits.into());
243 }
244
245 Ok(())
246}