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::hooks;
14use crate::registry;
15use crate::shared_files;
16
17use super::helpers::{build_hook_context, resolve_worktree_target};
18use crate::messages;
19
20/// Create a new worktree with a feature branch.
21#[allow(clippy::too_many_arguments)]
22pub fn create_worktree(
23    branch_name: &str,
24    base_branch: Option<&str>,
25    path: Option<&str>,
26    term: Option<&str>,
27    no_ai: bool,
28    initial_prompt: Option<&str>,
29    bg: bool,
30    fg: bool,
31) -> Result<PathBuf> {
32    let repo = git::get_repo_root(None)?;
33
34    // Validate branch name
35    if !git::is_valid_branch_name(branch_name, Some(&repo)) {
36        let error_msg = git::get_branch_name_error(branch_name);
37        return Err(CwError::InvalidBranch(messages::invalid_branch_name(
38            &error_msg,
39        )));
40    }
41
42    // Check if worktree already exists
43    let existing = git::find_worktree_by_branch(&repo, branch_name)?.or(
44        git::find_worktree_by_branch(&repo, &format!("refs/heads/{}", branch_name))?,
45    );
46
47    if let Some(existing_path) = existing {
48        println!(
49            "\n{}\nBranch '{}' already has a worktree at:\n  {}\n",
50            style("! Worktree already exists").yellow().bold(),
51            style(branch_name).cyan(),
52            style(existing_path.display()).blue(),
53        );
54
55        if git::is_non_interactive() {
56            return Err(CwError::InvalidBranch(format!(
57                "Worktree for branch '{}' already exists at {}.\n\
58                 Use 'gw resume {}' to continue work.",
59                branch_name,
60                existing_path.display(),
61                branch_name,
62            )));
63        }
64
65        // In interactive mode, suggest resume
66        println!(
67            "Use '{}' to resume work in this worktree.\n",
68            style(format!("gw resume {}", branch_name)).cyan()
69        );
70        return Ok(existing_path);
71    }
72
73    // Determine if branch already exists
74    let mut branch_already_exists = false;
75    let mut is_remote_only = false;
76
77    if git::branch_exists(branch_name, Some(&repo)) {
78        println!(
79            "\n{}\nBranch '{}' already exists locally but has no worktree.\n",
80            style("! Branch already exists").yellow().bold(),
81            style(branch_name).cyan(),
82        );
83        branch_already_exists = true;
84    } else if git::remote_branch_exists(branch_name, Some(&repo), "origin") {
85        println!(
86            "\n{}\nBranch '{}' exists on remote but not locally.\n",
87            style("! Remote branch found").yellow().bold(),
88            style(branch_name).cyan(),
89        );
90        branch_already_exists = true;
91        is_remote_only = true;
92    }
93
94    // Determine base branch
95    let base = if let Some(b) = base_branch {
96        b.to_string()
97    } else {
98        git::detect_default_branch(Some(&repo))
99    };
100
101    // Verify base branch
102    if (!is_remote_only || base_branch.is_some()) && !git::branch_exists(&base, Some(&repo)) {
103        return Err(CwError::InvalidBranch(messages::branch_not_found(&base)));
104    }
105
106    // Determine worktree path
107    let worktree_path = if let Some(p) = path {
108        PathBuf::from(p)
109            .canonicalize()
110            .unwrap_or_else(|_| PathBuf::from(p))
111    } else {
112        default_worktree_path(&repo, branch_name)
113    };
114
115    println!("\n{}", style("Creating new worktree:").cyan().bold());
116    println!("  Base branch: {}", style(&base).green());
117    println!("  New branch:  {}", style(branch_name).green());
118    println!("  Path:        {}\n", style(worktree_path.display()).blue());
119
120    // Pre-create hooks
121    let mut hook_ctx = build_hook_context(
122        branch_name,
123        &base,
124        &worktree_path,
125        &repo,
126        "worktree.pre_create",
127        "new",
128    );
129    hooks::run_hooks("worktree.pre_create", &hook_ctx, Some(&repo), Some(&repo))?;
130
131    // Create parent dir
132    if let Some(parent) = worktree_path.parent() {
133        let _ = std::fs::create_dir_all(parent);
134    }
135
136    // Fetch
137    let _ = git::git_command(&["fetch", "--all", "--prune"], Some(&repo), false, false);
138
139    // Create worktree
140    let wt_str = worktree_path.to_string_lossy().to_string();
141    if is_remote_only {
142        git::git_command(
143            &[
144                "worktree",
145                "add",
146                "-b",
147                branch_name,
148                &wt_str,
149                &format!("origin/{}", branch_name),
150            ],
151            Some(&repo),
152            true,
153            false,
154        )?;
155    } else if branch_already_exists {
156        git::git_command(
157            &["worktree", "add", &wt_str, branch_name],
158            Some(&repo),
159            true,
160            false,
161        )?;
162    } else {
163        git::git_command(
164            &["worktree", "add", "-b", branch_name, &wt_str, &base],
165            Some(&repo),
166            true,
167            false,
168        )?;
169    }
170
171    // Store metadata
172    let bb_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
173    let bp_key = format_config_key(CONFIG_KEY_BASE_PATH, branch_name);
174    let ib_key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, branch_name);
175    git::set_config(&bb_key, &base, Some(&repo))?;
176    git::set_config(&bp_key, &repo.to_string_lossy(), Some(&repo))?;
177    git::set_config(&ib_key, branch_name, Some(&repo))?;
178
179    // Register in global registry (non-fatal)
180    let _ = registry::register_repo(&repo);
181
182    println!(
183        "{} Worktree created successfully\n",
184        style("*").green().bold()
185    );
186
187    // Copy shared files
188    shared_files::share_files(&repo, &worktree_path);
189
190    // Post-create hooks
191    hook_ctx.insert("event".into(), "worktree.post_create".into());
192    let _ = hooks::run_hooks(
193        "worktree.post_create",
194        &hook_ctx,
195        Some(&worktree_path),
196        Some(&repo),
197    );
198
199    // Launch AI tool in the new worktree
200    if !no_ai {
201        let _ = super::ai_tools::launch_ai_tool(
202            &worktree_path,
203            term,
204            false,
205            None,
206            initial_prompt,
207            bg,
208            fg,
209        );
210    }
211
212    Ok(worktree_path)
213}
214
215/// Outcome of attempting to delete a single worktree.
216///
217/// `delete_one` itself returns only `Deleted` or `Failed` today; `Skipped` is
218/// carried for the batch orchestrator, which may classify an entry as skipped
219/// before `delete_one` would even be called (see `delete_batch::PlanEntry`).
220#[derive(Debug)]
221pub enum DeletionOutcome {
222    Deleted {
223        branch: Option<String>,
224        path: PathBuf,
225    },
226    Skipped {
227        reason: String,
228    },
229    Failed {
230        error: CwError,
231    },
232}
233
234/// Flags that apply uniformly to every target in a batch.
235#[derive(Debug, Clone, Copy)]
236pub struct DeleteFlags {
237    pub keep_branch: bool,
238    pub delete_remote: bool,
239    /// Passes through to `git worktree remove --force` (historical semantic).
240    pub git_force: bool,
241    /// Bypass the busy-detection gate.
242    pub allow_busy: bool,
243}
244
245/// Per-target deletion. Assumes the caller has already resolved the target
246/// and decided to proceed (no summary, no batch confirmation, no busy prompt
247/// — the orchestrator handles those).
248///
249/// Returns an outcome describing what happened. Never prints a batch summary;
250/// individual progress lines are acceptable.
251pub(crate) fn delete_one(
252    worktree_path: &Path,
253    branch_name: Option<&str>,
254    main_repo: &Path,
255    flags: DeleteFlags,
256) -> DeletionOutcome {
257    // Safety: never delete the main worktree.
258    let wt_resolved = git::canonicalize_or(worktree_path);
259    let main_resolved = git::canonicalize_or(main_repo);
260    if wt_resolved == main_resolved {
261        return DeletionOutcome::Failed {
262            error: CwError::Git(messages::cannot_delete_main_worktree()),
263        };
264    }
265
266    // If cwd is inside worktree, move to main_repo before deletion.
267    if let Ok(cwd) = std::env::current_dir() {
268        let cwd_canon = cwd.canonicalize().unwrap_or(cwd);
269        let wt_canon = worktree_path
270            .canonicalize()
271            .unwrap_or_else(|_| worktree_path.to_path_buf());
272        if cwd_canon.starts_with(&wt_canon) {
273            let _ = std::env::set_current_dir(main_repo);
274        }
275    }
276
277    // Pre-delete hook
278    let base_branch = branch_name
279        .and_then(|b| {
280            let key = format_config_key(CONFIG_KEY_BASE_BRANCH, b);
281            git::get_config(&key, Some(main_repo))
282        })
283        .unwrap_or_default();
284
285    let mut hook_ctx = build_hook_context(
286        branch_name.unwrap_or(""),
287        &base_branch,
288        worktree_path,
289        main_repo,
290        "worktree.pre_delete",
291        "delete",
292    );
293    if let Err(e) = hooks::run_hooks(
294        "worktree.pre_delete",
295        &hook_ctx,
296        Some(main_repo),
297        Some(main_repo),
298    ) {
299        return DeletionOutcome::Failed { error: e };
300    }
301
302    // Remove worktree
303    println!(
304        "{}",
305        style(messages::removing_worktree(worktree_path)).yellow()
306    );
307    if let Err(e) = git::remove_worktree_safe(worktree_path, main_repo, flags.git_force) {
308        return DeletionOutcome::Failed { error: e };
309    }
310    println!("{} Worktree removed\n", style("*").green().bold());
311
312    // Delete branch + metadata + optional remote push
313    if let Some(branch) = branch_name {
314        if !flags.keep_branch {
315            println!(
316                "{}",
317                style(messages::deleting_local_branch(branch)).yellow()
318            );
319            let _ = git::git_command(&["branch", "-D", branch], Some(main_repo), false, false);
320
321            let bb_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch);
322            let bp_key = format_config_key(CONFIG_KEY_BASE_PATH, branch);
323            let ib_key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, branch);
324            git::unset_config(&bb_key, Some(main_repo));
325            git::unset_config(&bp_key, Some(main_repo));
326            git::unset_config(&ib_key, Some(main_repo));
327
328            println!(
329                "{} Local branch and metadata removed\n",
330                style("*").green().bold()
331            );
332
333            if flags.delete_remote {
334                println!(
335                    "{}",
336                    style(messages::deleting_remote_branch(branch)).yellow()
337                );
338                match git::git_command(
339                    &["push", "origin", &format!(":{}", branch)],
340                    Some(main_repo),
341                    false,
342                    true,
343                ) {
344                    Ok(r) if r.returncode == 0 => {
345                        println!("{} Remote branch deleted\n", style("*").green().bold());
346                    }
347                    _ => {
348                        println!("{} Remote branch deletion failed\n", style("!").yellow());
349                    }
350                }
351            }
352        }
353    }
354
355    // Post-delete hook
356    hook_ctx.insert("event".into(), "worktree.post_delete".into());
357    let _ = hooks::run_hooks(
358        "worktree.post_delete",
359        &hook_ctx,
360        Some(main_repo),
361        Some(main_repo),
362    );
363    let _ = registry::update_last_seen(main_repo);
364
365    DeletionOutcome::Deleted {
366        branch: branch_name.map(str::to_string),
367        path: worktree_path.to_path_buf(),
368    }
369}
370
371/// Delete a worktree by branch name, worktree directory name, or path.
372///
373/// # Parameters
374///
375/// * `force` — historical `git worktree remove --force` semantic. Forwarded
376///   to `git::remove_worktree_safe`; controls whether git itself will remove
377///   a worktree with uncommitted changes. Defaults to `true` at the CLI.
378/// * `allow_busy` — bypass the gw-level busy-detection gate (lockfile +
379///   process cwd scan). Wired to the explicit `--force` CLI flag on the
380///   delete subcommand so users can override "worktree is in use" refusals.
381///
382/// These two flags are intentionally separate: the CLI `--force` is an
383/// affirmative user choice to bypass the busy check, whereas the git-force
384/// behaviour is a long-standing default that users rarely flip off.
385pub fn delete_worktree(
386    target: Option<&str>,
387    keep_branch: bool,
388    delete_remote: bool,
389    force: bool,
390    allow_busy: bool,
391    lookup_mode: Option<&str>,
392) -> Result<()> {
393    let main_repo = git::get_main_repo_root(None)?;
394    let (worktree_path, branch_name) = resolve_delete_target(target, &main_repo, lookup_mode)?;
395
396    // Main-repo safety guard (mirrors delete_one, but we want the error
397    // surfaced up before prompting).
398    let wt_resolved = git::canonicalize_or(&worktree_path);
399    let main_resolved = git::canonicalize_or(&main_repo);
400    if wt_resolved == main_resolved {
401        return Err(CwError::Git(messages::cannot_delete_main_worktree()));
402    }
403
404    // If cwd is inside worktree, change to main repo *before* busy detection
405    // so the current process itself doesn't register as a busy holder.
406    // Canonicalize both sides so /var vs /private/var (macOS) and other
407    // symlink skew do not hide the match.
408    if let Ok(cwd) = std::env::current_dir() {
409        let cwd_canon = cwd.canonicalize().unwrap_or(cwd);
410        let wt_canon = worktree_path
411            .canonicalize()
412            .unwrap_or_else(|_| worktree_path.clone());
413        if cwd_canon.starts_with(&wt_canon) {
414            let _ = std::env::set_current_dir(&main_repo);
415        }
416    }
417
418    let (hard, soft) = crate::operations::busy::detect_busy_tiered(&worktree_path);
419    if (!hard.is_empty() || !soft.is_empty()) && !allow_busy {
420        let branch_display = branch_name.clone().unwrap_or_else(|| {
421            worktree_path
422                .file_name()
423                .map(|n| n.to_string_lossy().to_string())
424                .unwrap_or_else(|| worktree_path.to_string_lossy().to_string())
425        });
426        let msg = crate::operations::busy_messages::render_refusal(&branch_display, &hard, &soft);
427        eprint!("{}", msg);
428        return Err(CwError::Other(format!(
429            "worktree '{}' is in use; re-run with --force to override",
430            branch_display
431        )));
432    }
433
434    let flags = DeleteFlags {
435        keep_branch,
436        delete_remote,
437        git_force: force,
438        allow_busy: true, // already gated above
439    };
440
441    match delete_one(&worktree_path, branch_name.as_deref(), &main_repo, flags) {
442        DeletionOutcome::Deleted { .. } => Ok(()),
443        DeletionOutcome::Skipped { reason } => Err(CwError::Other(reason)),
444        DeletionOutcome::Failed { error } => Err(error),
445    }
446}
447
448/// Resolve delete target to (worktree_path, branch_name).
449fn resolve_delete_target(
450    target: Option<&str>,
451    main_repo: &Path,
452    lookup_mode: Option<&str>,
453) -> Result<(PathBuf, Option<String>)> {
454    let target = target.map(|t| t.to_string()).unwrap_or_else(|| {
455        std::env::current_dir()
456            .unwrap_or_default()
457            .to_string_lossy()
458            .to_string()
459    });
460
461    let target_path = PathBuf::from(&target);
462
463    // Check if it's a filesystem path
464    if target_path.exists() {
465        let resolved = target_path.canonicalize().unwrap_or(target_path);
466        let branch = super::helpers::get_branch_for_worktree(main_repo, &resolved);
467        return Ok((resolved, branch));
468    }
469
470    // Try branch lookup (skip if lookup_mode is "worktree")
471    if lookup_mode != Some("worktree") {
472        if let Some(path) = git::find_worktree_by_intended_branch(main_repo, &target)? {
473            return Ok((path, Some(target)));
474        }
475    }
476
477    // Try worktree name lookup (skip if lookup_mode is "branch")
478    if lookup_mode != Some("branch") {
479        if let Some(path) = git::find_worktree_by_name(main_repo, &target)? {
480            let branch = super::helpers::get_branch_for_worktree(main_repo, &path);
481            return Ok((path, branch));
482        }
483    }
484
485    Err(CwError::WorktreeNotFound(messages::worktree_not_found(
486        &target,
487    )))
488}
489
490/// Sync worktree with base branch.
491pub fn sync_worktree(
492    target: Option<&str>,
493    all: bool,
494    _fetch_only: bool,
495    ai_merge: bool,
496    lookup_mode: Option<&str>,
497) -> Result<()> {
498    let repo = git::get_repo_root(None)?;
499
500    // Fetch first
501    println!("{}", style("Fetching updates from remote...").yellow());
502    let fetch_result = git::git_command(&["fetch", "--all", "--prune"], Some(&repo), false, true)?;
503    if fetch_result.returncode != 0 {
504        println!(
505            "{} Fetch failed or no remote configured\n",
506            style("!").yellow()
507        );
508    }
509
510    if _fetch_only {
511        println!("{} Fetch complete\n", style("*").green().bold());
512        return Ok(());
513    }
514
515    // Determine worktrees to sync
516    let worktrees_to_sync = if all {
517        let all_wt = git::parse_worktrees(&repo)?;
518        all_wt
519            .into_iter()
520            .filter(|(b, _)| b != "(detached)")
521            .map(|(b, p)| {
522                let branch = git::normalize_branch_name(&b).to_string();
523                (branch, p)
524            })
525            .collect::<Vec<_>>()
526    } else {
527        let resolved = resolve_worktree_target(target, lookup_mode)?;
528        vec![(resolved.branch, resolved.path)]
529    };
530
531    for (branch, wt_path) in &worktrees_to_sync {
532        let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch);
533        let base_branch = git::get_config(&base_key, Some(&repo));
534
535        if let Some(base) = base_branch {
536            println!("\n{}", style("Syncing worktree:").cyan().bold());
537            println!("  Branch: {}", style(branch).green());
538            println!("  Base:   {}", style(&base).green());
539            println!("  Path:   {}\n", style(wt_path.display()).blue());
540
541            // Determine rebase target (fetch already done above)
542            let rebase_target = {
543                let origin_base = format!("origin/{}", base);
544                if git::branch_exists(&origin_base, Some(wt_path)) {
545                    origin_base
546                } else {
547                    base.clone()
548                }
549            };
550
551            println!(
552                "{}",
553                style(messages::rebase_in_progress(branch, &rebase_target)).yellow()
554            );
555
556            match git::git_command(&["rebase", &rebase_target], Some(wt_path), false, true) {
557                Ok(r) if r.returncode == 0 => {
558                    println!("{} Rebase successful\n", style("*").green().bold());
559                }
560                _ => {
561                    if ai_merge {
562                        let conflicts = git::list_conflicted_files(wt_path);
563                        let _ =
564                            git::git_command(&["rebase", "--abort"], Some(wt_path), false, false);
565
566                        let conflict_list = conflicts.as_deref().unwrap_or("(unknown)");
567                        let prompt = format!(
568                            "Resolve merge conflicts in this repository. The rebase of '{}' onto '{}' \
569                             failed with conflicts in: {}\n\
570                             Please examine the conflicted files and resolve them.",
571                            branch, rebase_target, conflict_list
572                        );
573
574                        println!(
575                            "\n{} Launching AI to resolve conflicts for '{}'...\n",
576                            style("*").cyan().bold(),
577                            branch
578                        );
579                        let _ = super::ai_tools::launch_ai_tool(
580                            wt_path,
581                            None,
582                            false,
583                            Some(&prompt),
584                            None,
585                            false,
586                            false,
587                        );
588                    } else {
589                        // Abort rebase on failure
590                        let _ =
591                            git::git_command(&["rebase", "--abort"], Some(wt_path), false, false);
592                        println!(
593                            "{} Rebase failed for '{}'. Resolve conflicts manually.\n\
594                             Tip: Use --ai-merge flag to get AI assistance with conflicts\n",
595                            style("!").yellow(),
596                            branch
597                        );
598                    }
599                }
600            }
601        } else {
602            // No base branch metadata — try origin/branch
603            let origin_ref = format!("origin/{}", branch);
604            if git::branch_exists(&origin_ref, Some(wt_path)) {
605                println!("\n{}", style("Syncing worktree:").cyan().bold());
606                println!("  Branch: {}", style(branch).green());
607                println!("  Path:   {}\n", style(wt_path.display()).blue());
608
609                println!(
610                    "{}",
611                    style(messages::rebase_in_progress(branch, &origin_ref)).yellow()
612                );
613
614                match git::git_command(&["rebase", &origin_ref], Some(wt_path), false, true) {
615                    Ok(r) if r.returncode == 0 => {
616                        println!("{} Rebase successful\n", style("*").green().bold());
617                    }
618                    _ => {
619                        let _ =
620                            git::git_command(&["rebase", "--abort"], Some(wt_path), false, false);
621                        println!(
622                            "{} Rebase failed for '{}'. Resolve conflicts manually.\n",
623                            style("!").yellow(),
624                            branch
625                        );
626                    }
627                }
628            }
629        }
630    }
631
632    Ok(())
633}