Skip to main content

git_worktree_manager/operations/
worktree.rs

1/// Core worktree lifecycle operations.
2///
3use std::path::{Path, PathBuf};
4
5use console::style;
6
7use crate::constants::{
8    default_worktree_path, format_config_key, CONFIG_KEY_BASE_BRANCH, CONFIG_KEY_BASE_PATH,
9    CONFIG_KEY_INTENDED_BRANCH,
10};
11use crate::error::{CwError, Result};
12use crate::git;
13use crate::shared_files;
14
15use crate::messages;
16
17/// Create a new worktree with a feature branch.
18pub fn create_worktree(
19    branch_name: &str,
20    base_branch: Option<&str>,
21    path: Option<&str>,
22    no_ai: bool,
23    initial_prompt: Option<&str>,
24) -> Result<PathBuf> {
25    let repo = git::get_repo_root(None)?;
26
27    // Validate branch name
28    if !git::is_valid_branch_name(branch_name, Some(&repo)) {
29        let error_msg = git::get_branch_name_error(branch_name);
30        return Err(CwError::InvalidBranch(messages::invalid_branch_name(
31            &error_msg,
32        )));
33    }
34
35    // Check if worktree already exists
36    let existing = git::find_worktree_by_branch(&repo, branch_name)?.or(
37        git::find_worktree_by_branch(&repo, &format!("refs/heads/{}", branch_name))?,
38    );
39
40    if let Some(existing_path) = existing {
41        println!(
42            "\n{}\nBranch '{}' already has a worktree at:\n  {}\n",
43            style("! Worktree already exists").yellow().bold(),
44            style(branch_name).cyan(),
45            style(existing_path.display()).blue(),
46        );
47
48        if git::is_non_interactive() {
49            return Err(CwError::InvalidBranch(format!(
50                "Worktree for branch '{}' already exists at {}.\n\
51                 Use 'gw resume {}' to continue work.",
52                branch_name,
53                existing_path.display(),
54                branch_name,
55            )));
56        }
57
58        // In interactive mode, suggest resume
59        println!(
60            "Use '{}' to resume work in this worktree.\n",
61            style(format!("gw resume {}", branch_name)).cyan()
62        );
63        return Ok(existing_path);
64    }
65
66    // Determine if branch already exists
67    let mut branch_already_exists = false;
68    let mut is_remote_only = false;
69
70    if git::branch_exists(branch_name, Some(&repo)) {
71        println!(
72            "\n{}\nBranch '{}' already exists locally but has no worktree.\n",
73            style("! Branch already exists").yellow().bold(),
74            style(branch_name).cyan(),
75        );
76        branch_already_exists = true;
77    } else if git::remote_branch_exists(branch_name, Some(&repo), "origin") {
78        println!(
79            "\n{}\nBranch '{}' exists on remote but not locally.\n",
80            style("! Remote branch found").yellow().bold(),
81            style(branch_name).cyan(),
82        );
83        branch_already_exists = true;
84        is_remote_only = true;
85    }
86
87    // Determine base branch
88    let base = if let Some(b) = base_branch {
89        b.to_string()
90    } else {
91        git::detect_default_branch(Some(&repo))
92    };
93
94    // Verify base branch
95    if (!is_remote_only || base_branch.is_some()) && !git::branch_exists(&base, Some(&repo)) {
96        return Err(CwError::InvalidBranch(messages::branch_not_found(&base)));
97    }
98
99    // Determine worktree path
100    let worktree_path = if let Some(p) = path {
101        PathBuf::from(p)
102            .canonicalize()
103            .unwrap_or_else(|_| PathBuf::from(p))
104    } else {
105        default_worktree_path(&repo, branch_name)
106    };
107
108    println!("\n{}", style("Creating new worktree:").cyan().bold());
109    println!("  Base branch: {}", style(&base).green());
110    println!("  New branch:  {}", style(branch_name).green());
111    println!("  Path:        {}\n", style(worktree_path.display()).blue());
112
113    // Create parent dir
114    if let Some(parent) = worktree_path.parent() {
115        let _ = std::fs::create_dir_all(parent);
116    }
117
118    // Fetch
119    let _ = git::git_command(&["fetch", "--all", "--prune"], Some(&repo), false, false);
120
121    // Create worktree
122    let wt_str = worktree_path.to_string_lossy().to_string();
123    if is_remote_only {
124        git::git_command(
125            &[
126                "worktree",
127                "add",
128                "-b",
129                branch_name,
130                &wt_str,
131                &format!("origin/{}", branch_name),
132            ],
133            Some(&repo),
134            true,
135            false,
136        )?;
137    } else if branch_already_exists {
138        git::git_command(
139            &["worktree", "add", &wt_str, branch_name],
140            Some(&repo),
141            true,
142            false,
143        )?;
144    } else {
145        git::git_command(
146            &["worktree", "add", "-b", branch_name, &wt_str, &base],
147            Some(&repo),
148            true,
149            false,
150        )?;
151    }
152
153    // Store metadata
154    let bb_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
155    let bp_key = format_config_key(CONFIG_KEY_BASE_PATH, branch_name);
156    let ib_key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, branch_name);
157    git::set_config(&bb_key, &base, Some(&repo))?;
158    git::set_config(&bp_key, &repo.to_string_lossy(), Some(&repo))?;
159    git::set_config(&ib_key, branch_name, Some(&repo))?;
160
161    println!(
162        "{} Worktree created successfully\n",
163        style("*").green().bold()
164    );
165
166    // Copy shared files
167    shared_files::share_files(&repo, &worktree_path);
168
169    // post_new fires after the worktree is on disk, so a non-zero exit
170    // can't unwind the create. We propagate the error so the CLI exits
171    // non-zero (the standard `Error: ...` printer in `entrypoint::run`
172    // surfaces the cause); the worktree itself stays. The AI-tool launch
173    // is skipped because the user signalled "this worktree isn't ready."
174    crate::hooks::run_event("post_new", &worktree_path)?;
175
176    // Launch AI tool in the new worktree.
177    if !no_ai {
178        let _ = super::ai_tools::spawn_in_worktree(&worktree_path, initial_prompt);
179    }
180
181    Ok(worktree_path)
182}
183
184/// Outcome of attempting to delete a single worktree.
185///
186/// `delete_one` itself returns only `Deleted` or `Failed` today; `Skipped` is
187/// carried for the batch orchestrator, which may classify an entry as skipped
188/// before `delete_one` would even be called (see `rm_batch::PlanEntry`).
189#[derive(Debug)]
190pub enum DeletionOutcome {
191    Deleted {
192        branch: Option<String>,
193        path: PathBuf,
194    },
195    Skipped {
196        reason: String,
197    },
198    Failed {
199        error: CwError,
200    },
201}
202
203/// Flags that apply uniformly to every target in a batch.
204#[derive(Debug, Clone, Copy)]
205pub struct RmFlags {
206    pub keep_branch: bool,
207    pub delete_remote: bool,
208    /// Passes through to `git worktree remove --force` (historical semantic).
209    pub git_force: bool,
210    /// Bypass the busy-detection gate.
211    pub allow_busy: bool,
212}
213
214/// Per-target deletion. Assumes the caller has already resolved the target
215/// and decided to proceed (no summary, no batch confirmation, no busy prompt
216/// — the orchestrator handles those).
217///
218/// Returns an outcome describing what happened. Never prints a batch summary;
219/// individual progress lines are acceptable.
220pub(crate) fn delete_one(
221    worktree_path: &Path,
222    branch_name: Option<&str>,
223    main_repo: &Path,
224    flags: RmFlags,
225) -> DeletionOutcome {
226    // Safety: never delete the main worktree.
227    let wt_resolved = git::canonicalize_or(worktree_path);
228    let main_resolved = git::canonicalize_or(main_repo);
229    if wt_resolved == main_resolved {
230        return DeletionOutcome::Failed {
231            error: CwError::Git(messages::cannot_delete_main_worktree()),
232        };
233    }
234
235    // If cwd is inside worktree, move to main_repo before deletion.
236    if let Ok(cwd) = std::env::current_dir() {
237        let cwd_canon = cwd.canonicalize().unwrap_or(cwd);
238        let wt_canon = worktree_path
239            .canonicalize()
240            .unwrap_or_else(|_| worktree_path.to_path_buf());
241        if cwd_canon.starts_with(&wt_canon) {
242            let _ = std::env::set_current_dir(main_repo);
243        }
244    }
245
246    // pre_rm fires unconditionally — `--force` bypasses the busy-detection
247    // gate, not the user's hook. A non-zero exit from the hook aborts the
248    // remove (matches the README contract).
249    if let Err(e) = crate::hooks::run_event("pre_rm", worktree_path) {
250        return DeletionOutcome::Failed { error: e };
251    }
252
253    // Remove worktree
254    println!(
255        "{}",
256        style(messages::removing_worktree(worktree_path)).yellow()
257    );
258    if let Err(e) = git::remove_worktree_safe(worktree_path, main_repo, flags.git_force) {
259        return DeletionOutcome::Failed { error: e };
260    }
261    println!("{} Worktree removed\n", style("*").green().bold());
262
263    // Delete branch + metadata + optional remote push
264    if let Some(branch) = branch_name {
265        if !flags.keep_branch {
266            println!(
267                "{}",
268                style(messages::deleting_local_branch(branch)).yellow()
269            );
270            let _ = git::git_command(&["branch", "-D", branch], Some(main_repo), false, false);
271
272            let bb_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch);
273            let bp_key = format_config_key(CONFIG_KEY_BASE_PATH, branch);
274            let ib_key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, branch);
275            git::unset_config(&bb_key, Some(main_repo));
276            git::unset_config(&bp_key, Some(main_repo));
277            git::unset_config(&ib_key, Some(main_repo));
278
279            println!(
280                "{} Local branch and metadata removed\n",
281                style("*").green().bold()
282            );
283
284            if flags.delete_remote {
285                println!(
286                    "{}",
287                    style(messages::deleting_remote_branch(branch)).yellow()
288                );
289                match git::git_command(
290                    &["push", "origin", &format!(":{}", branch)],
291                    Some(main_repo),
292                    false,
293                    true,
294                ) {
295                    Ok(r) if r.returncode == 0 => {
296                        println!("{} Remote branch deleted\n", style("*").green().bold());
297                    }
298                    _ => {
299                        println!("{} Remote branch deletion failed\n", style("!").yellow());
300                    }
301                }
302            }
303        }
304    }
305
306    DeletionOutcome::Deleted {
307        branch: branch_name.map(str::to_string),
308        path: worktree_path.to_path_buf(),
309    }
310}
311
312/// Delete a worktree by branch name, worktree directory name, or path.
313///
314/// # Parameters
315///
316/// * `force` — historical `git worktree remove --force` semantic. Forwarded
317///   to `git::remove_worktree_safe`; controls whether git itself will remove
318///   a worktree with uncommitted changes. Defaults to `true` at the CLI.
319/// * `allow_busy` — bypass the gw-level busy-detection gate (lockfile +
320///   process cwd scan). Wired to the explicit `--force` CLI flag on the
321///   delete subcommand so users can override "worktree is in use" refusals.
322///
323/// These two flags are intentionally separate: the CLI `--force` is an
324/// affirmative user choice to bypass the busy check, whereas the git-force
325/// behaviour is a long-standing default that users rarely flip off.
326pub fn delete_worktree(
327    target: Option<&str>,
328    keep_branch: bool,
329    delete_remote: bool,
330    force: bool,
331    allow_busy: bool,
332) -> Result<()> {
333    let main_repo = git::get_main_repo_root(None)?;
334    let (worktree_path, branch_name) = resolve_delete_target(target, &main_repo)?;
335
336    // Main-repo safety guard (mirrors delete_one, but we want the error
337    // surfaced up before prompting).
338    let wt_resolved = git::canonicalize_or(&worktree_path);
339    let main_resolved = git::canonicalize_or(&main_repo);
340    if wt_resolved == main_resolved {
341        return Err(CwError::Git(messages::cannot_delete_main_worktree()));
342    }
343
344    // If cwd is inside worktree, change to main repo *before* busy detection
345    // so the current process itself doesn't register as a busy holder.
346    // Canonicalize both sides so /var vs /private/var (macOS) and other
347    // symlink skew do not hide the match.
348    if let Ok(cwd) = std::env::current_dir() {
349        let cwd_canon = cwd.canonicalize().unwrap_or(cwd);
350        let wt_canon = worktree_path
351            .canonicalize()
352            .unwrap_or_else(|_| worktree_path.clone());
353        if cwd_canon.starts_with(&wt_canon) {
354            let _ = std::env::set_current_dir(&main_repo);
355        }
356    }
357
358    let (hard, soft) = crate::operations::busy::detect_busy_tiered(&worktree_path);
359    if (!hard.is_empty() || !soft.is_empty()) && !allow_busy {
360        let branch_display = branch_name.clone().unwrap_or_else(|| {
361            worktree_path
362                .file_name()
363                .map(|n| n.to_string_lossy().to_string())
364                .unwrap_or_else(|| worktree_path.to_string_lossy().to_string())
365        });
366        let msg = crate::operations::busy_messages::render_refusal(&branch_display, &hard, &soft);
367        eprint!("{}", msg);
368        return Err(CwError::Other(format!(
369            "worktree '{}' is in use; re-run with --force to override",
370            branch_display
371        )));
372    }
373
374    let flags = RmFlags {
375        keep_branch,
376        delete_remote,
377        git_force: force,
378        allow_busy: true, // already gated above
379    };
380
381    match delete_one(&worktree_path, branch_name.as_deref(), &main_repo, flags) {
382        DeletionOutcome::Deleted { .. } => Ok(()),
383        DeletionOutcome::Skipped { reason } => Err(CwError::Other(reason)),
384        DeletionOutcome::Failed { error } => Err(error),
385    }
386}
387
388/// Resolve delete target to (worktree_path, branch_name).
389///
390/// Uses strict ordered resolution: exact worktree name → exact branch → exact path.
391/// When `target` is `None`, falls back to cwd as the target path.
392fn resolve_delete_target(
393    target: Option<&str>,
394    main_repo: &Path,
395) -> Result<(PathBuf, Option<String>)> {
396    let target = target.map(|t| t.to_string()).unwrap_or_else(|| {
397        std::env::current_dir()
398            .unwrap_or_default()
399            .to_string_lossy()
400            .to_string()
401    });
402
403    let strict = super::helpers::resolve_target_strict(main_repo, &target)?;
404    Ok((strict.path, strict.branch))
405}