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::messages;
17
18/// Create a new worktree with a feature branch.
19pub fn create_worktree(
20    branch_name: &str,
21    base_branch: Option<&str>,
22    path: Option<&str>,
23    initial_prompt: Option<&str>,
24    launch_opts: &LaunchOptions<'_>,
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. `-T skip|none|noop` (the
178    // replacement for the old `--no-term`) is handled inside
179    // spawn_in_worktree itself, so we always call through. Errors from the
180    // launcher are swallowed here: the worktree is on disk and usable
181    // either way, and the user can re-launch via `gw spawn`.
182    let _ = super::ai_tools::spawn_in_worktree(&worktree_path, initial_prompt, launch_opts);
183
184    Ok(worktree_path)
185}
186
187/// Outcome of attempting to delete a single worktree.
188///
189/// `delete_one` itself returns only `Deleted` or `Failed` today; `Skipped` is
190/// carried for the batch orchestrator, which may classify an entry as skipped
191/// before `delete_one` would even be called (see `rm_batch::PlanEntry`).
192#[derive(Debug)]
193pub enum DeletionOutcome {
194    Deleted {
195        branch: Option<String>,
196        path: PathBuf,
197    },
198    Skipped {
199        reason: String,
200    },
201    Failed {
202        error: CwError,
203    },
204}
205
206/// Flags that apply uniformly to every target in a batch.
207#[derive(Debug, Clone, Copy)]
208pub struct RmFlags {
209    pub keep_branch: bool,
210    pub delete_remote: bool,
211    /// Passes through to `git worktree remove --force` (historical semantic).
212    pub git_force: bool,
213    /// Bypass the busy-detection gate.
214    pub allow_busy: bool,
215}
216
217/// Per-target deletion. Assumes the caller has already resolved the target
218/// and decided to proceed (no summary, no batch confirmation, no busy prompt
219/// — the orchestrator handles those).
220///
221/// Returns an outcome describing what happened. Never prints a batch summary;
222/// individual progress lines are acceptable.
223pub(crate) fn delete_one(
224    worktree_path: &Path,
225    branch_name: Option<&str>,
226    main_repo: &Path,
227    flags: RmFlags,
228) -> DeletionOutcome {
229    // Safety: never delete the main worktree.
230    let wt_resolved = git::canonicalize_or(worktree_path);
231    let main_resolved = git::canonicalize_or(main_repo);
232    if wt_resolved == main_resolved {
233        return DeletionOutcome::Failed {
234            error: CwError::Git(messages::cannot_delete_main_worktree()),
235        };
236    }
237
238    // If cwd is inside worktree, move to main_repo before deletion.
239    if let Ok(cwd) = std::env::current_dir() {
240        let cwd_canon = cwd.canonicalize().unwrap_or(cwd);
241        let wt_canon = worktree_path
242            .canonicalize()
243            .unwrap_or_else(|_| worktree_path.to_path_buf());
244        if cwd_canon.starts_with(&wt_canon) {
245            let _ = std::env::set_current_dir(main_repo);
246        }
247    }
248
249    // pre_rm fires unconditionally — `--force` bypasses the busy-detection
250    // gate, not the user's hook. A non-zero exit from the hook aborts the
251    // remove (matches the README contract).
252    if let Err(e) = crate::hooks::run_event("pre_rm", worktree_path) {
253        return DeletionOutcome::Failed { error: e };
254    }
255
256    // Remove worktree
257    println!(
258        "{}",
259        style(messages::removing_worktree(worktree_path)).yellow()
260    );
261    if let Err(e) = git::remove_worktree_safe(worktree_path, main_repo, flags.git_force) {
262        return DeletionOutcome::Failed { error: e };
263    }
264    println!("{} Worktree removed\n", style("*").green().bold());
265
266    // Delete branch + metadata + optional remote push
267    if let Some(branch) = branch_name {
268        if !flags.keep_branch {
269            println!(
270                "{}",
271                style(messages::deleting_local_branch(branch)).yellow()
272            );
273            let _ = git::git_command(&["branch", "-D", branch], Some(main_repo), false, false);
274
275            let bb_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch);
276            let bp_key = format_config_key(CONFIG_KEY_BASE_PATH, branch);
277            let ib_key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, branch);
278            git::unset_config(&bb_key, Some(main_repo));
279            git::unset_config(&bp_key, Some(main_repo));
280            git::unset_config(&ib_key, Some(main_repo));
281
282            println!(
283                "{} Local branch and metadata removed\n",
284                style("*").green().bold()
285            );
286
287            if flags.delete_remote {
288                println!(
289                    "{}",
290                    style(messages::deleting_remote_branch(branch)).yellow()
291                );
292                match git::git_command(
293                    &["push", "origin", &format!(":{}", branch)],
294                    Some(main_repo),
295                    false,
296                    true,
297                ) {
298                    Ok(r) if r.returncode == 0 => {
299                        println!("{} Remote branch deleted\n", style("*").green().bold());
300                    }
301                    _ => {
302                        println!("{} Remote branch deletion failed\n", style("!").yellow());
303                    }
304                }
305            }
306        }
307    }
308
309    DeletionOutcome::Deleted {
310        branch: branch_name.map(str::to_string),
311        path: worktree_path.to_path_buf(),
312    }
313}
314
315/// Delete a worktree by branch name, worktree directory name, or path.
316///
317/// # Parameters
318///
319/// * `force` — historical `git worktree remove --force` semantic. Forwarded
320///   to `git::remove_worktree_safe`; controls whether git itself will remove
321///   a worktree with uncommitted changes. Defaults to `true` at the CLI.
322/// * `allow_busy` — bypass the gw-level busy-detection gate (lockfile +
323///   process cwd scan). Wired to the explicit `--force` CLI flag on the
324///   delete subcommand so users can override "worktree is in use" refusals.
325///
326/// These two flags are intentionally separate: the CLI `--force` is an
327/// affirmative user choice to bypass the busy check, whereas the git-force
328/// behaviour is a long-standing default that users rarely flip off.
329pub fn delete_worktree(
330    target: Option<&str>,
331    keep_branch: bool,
332    delete_remote: bool,
333    force: bool,
334    allow_busy: bool,
335) -> Result<()> {
336    let main_repo = git::get_main_repo_root(None)?;
337    let (worktree_path, branch_name) = resolve_delete_target(target, &main_repo)?;
338
339    // Main-repo safety guard (mirrors delete_one, but we want the error
340    // surfaced up before prompting).
341    let wt_resolved = git::canonicalize_or(&worktree_path);
342    let main_resolved = git::canonicalize_or(&main_repo);
343    if wt_resolved == main_resolved {
344        return Err(CwError::Git(messages::cannot_delete_main_worktree()));
345    }
346
347    // If cwd is inside worktree, change to main repo *before* busy detection
348    // so the current process itself doesn't register as a busy holder.
349    // Canonicalize both sides so /var vs /private/var (macOS) and other
350    // symlink skew do not hide the match.
351    if let Ok(cwd) = std::env::current_dir() {
352        let cwd_canon = cwd.canonicalize().unwrap_or(cwd);
353        let wt_canon = worktree_path
354            .canonicalize()
355            .unwrap_or_else(|_| worktree_path.clone());
356        if cwd_canon.starts_with(&wt_canon) {
357            let _ = std::env::set_current_dir(&main_repo);
358        }
359    }
360
361    let (hard, soft) = crate::operations::busy::detect_busy_tiered(&worktree_path);
362    if (!hard.is_empty() || !soft.is_empty()) && !allow_busy {
363        let branch_display = branch_name.clone().unwrap_or_else(|| {
364            worktree_path
365                .file_name()
366                .map(|n| n.to_string_lossy().to_string())
367                .unwrap_or_else(|| worktree_path.to_string_lossy().to_string())
368        });
369        let msg = crate::operations::busy_messages::render_refusal(&branch_display, &hard, &soft);
370        eprint!("{}", msg);
371        return Err(CwError::Other(format!(
372            "worktree '{}' is in use; re-run with --force to override",
373            branch_display
374        )));
375    }
376
377    let flags = RmFlags {
378        keep_branch,
379        delete_remote,
380        git_force: force,
381        allow_busy: true, // already gated above
382    };
383
384    match delete_one(&worktree_path, branch_name.as_deref(), &main_repo, flags) {
385        DeletionOutcome::Deleted { .. } => Ok(()),
386        DeletionOutcome::Skipped { reason } => Err(CwError::Other(reason)),
387        DeletionOutcome::Failed { error } => Err(error),
388    }
389}
390
391/// Resolve delete target to (worktree_path, branch_name).
392///
393/// Uses strict ordered resolution: exact worktree name → exact branch → exact path.
394/// When `target` is `None`, falls back to cwd as the target path.
395fn resolve_delete_target(
396    target: Option<&str>,
397    main_repo: &Path,
398) -> Result<(PathBuf, Option<String>)> {
399    let target = target.map(|t| t.to_string()).unwrap_or_else(|| {
400        std::env::current_dir()
401            .unwrap_or_default()
402            .to_string_lossy()
403            .to_string()
404    });
405
406    let strict = super::helpers::resolve_target_strict(main_repo, &target)?;
407    Ok((strict.path, strict.branch))
408}