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