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