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