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/// Delete a worktree by branch name, worktree directory name, or path.
205///
206/// # Parameters
207///
208/// * `force` — historical `git worktree remove --force` semantic. Forwarded
209///   to `git::remove_worktree_safe`; controls whether git itself will remove
210///   a worktree with uncommitted changes. Defaults to `true` at the CLI.
211/// * `allow_busy` — bypass the gw-level busy-detection gate (lockfile +
212///   process cwd scan). Wired to the explicit `--force` CLI flag on the
213///   delete subcommand so users can override "worktree is in use" refusals.
214///
215/// These two flags are intentionally separate: the CLI `--force` is an
216/// affirmative user choice to bypass the busy check, whereas the git-force
217/// behaviour is a long-standing default that users rarely flip off.
218pub fn delete_worktree(
219    target: Option<&str>,
220    keep_branch: bool,
221    delete_remote: bool,
222    force: bool,
223    allow_busy: bool,
224    lookup_mode: Option<&str>,
225) -> Result<()> {
226    let main_repo = git::get_main_repo_root(None)?;
227    let (worktree_path, branch_name) = resolve_delete_target(target, &main_repo, lookup_mode)?;
228
229    // Safety: don't delete main repo
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 Err(CwError::Git(messages::cannot_delete_main_worktree()));
234    }
235
236    // If cwd is inside worktree, change to main repo. Canonicalize both
237    // sides so /var vs /private/var (macOS) and other symlink skew do not
238    // hide the match.
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.clone());
244        if cwd_canon.starts_with(&wt_canon) {
245            let _ = std::env::set_current_dir(&main_repo);
246        }
247    }
248
249    // Busy-detection gate: block deletion if worktree is in use, unless the
250    // caller explicitly opted in via `allow_busy` (wired to the explicit
251    // `--force` CLI flag — note that the `force` parameter here carries the
252    // historical git-force semantic and defaults to true, so we use a
253    // dedicated flag for the busy override).
254    let busy = crate::operations::busy::detect_busy(&worktree_path);
255    if !busy.is_empty() && !allow_busy {
256        let branch_display = branch_name.clone().unwrap_or_else(|| {
257            worktree_path
258                .file_name()
259                .map(|n| n.to_string_lossy().to_string())
260                .unwrap_or_else(|| worktree_path.to_string_lossy().to_string())
261        });
262        eprintln!(
263            "{} worktree '{}' is in use by:",
264            style("error:").red().bold(),
265            branch_display
266        );
267        for b in &busy {
268            eprintln!("    PID {:>6}  {}  (source: {:?})", b.pid, b.cmd, b.source);
269        }
270
271        use std::io::IsTerminal;
272        if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() {
273            use std::io::Write;
274            eprint!("Delete anyway? (y/N): ");
275            let _ = std::io::stderr().flush();
276            let mut buf = String::new();
277            std::io::stdin().read_line(&mut buf)?;
278            let ans = buf.trim().to_lowercase();
279            if ans != "y" && ans != "yes" {
280                eprintln!("Aborted.");
281                return Ok(());
282            }
283        } else {
284            return Err(CwError::Other(format!(
285                "worktree '{}' is in use by {} process(es); re-run with --force to override",
286                branch_display,
287                busy.len()
288            )));
289        }
290    }
291
292    // Pre-delete hooks
293    let base_branch = branch_name
294        .as_deref()
295        .and_then(|b| {
296            let key = format_config_key(CONFIG_KEY_BASE_BRANCH, b);
297            git::get_config(&key, Some(&main_repo))
298        })
299        .unwrap_or_default();
300
301    let mut hook_ctx = build_hook_context(
302        &branch_name.clone().unwrap_or_default(),
303        &base_branch,
304        &worktree_path,
305        &main_repo,
306        "worktree.pre_delete",
307        "delete",
308    );
309    hooks::run_hooks(
310        "worktree.pre_delete",
311        &hook_ctx,
312        Some(&main_repo),
313        Some(&main_repo),
314    )?;
315
316    // Remove worktree
317    println!(
318        "{}",
319        style(messages::removing_worktree(&worktree_path)).yellow()
320    );
321    git::remove_worktree_safe(&worktree_path, &main_repo, force)?;
322    println!("{} Worktree removed\n", style("*").green().bold());
323
324    // Delete branch
325    if let Some(ref branch) = branch_name {
326        if !keep_branch {
327            println!(
328                "{}",
329                style(messages::deleting_local_branch(branch)).yellow()
330            );
331            let _ = git::git_command(&["branch", "-D", branch], Some(&main_repo), false, false);
332
333            // Remove metadata
334            let bb_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch);
335            let bp_key = format_config_key(CONFIG_KEY_BASE_PATH, branch);
336            let ib_key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, branch);
337            git::unset_config(&bb_key, Some(&main_repo));
338            git::unset_config(&bp_key, Some(&main_repo));
339            git::unset_config(&ib_key, Some(&main_repo));
340
341            println!(
342                "{} Local branch and metadata removed\n",
343                style("*").green().bold()
344            );
345
346            // Delete remote branch
347            if delete_remote {
348                println!(
349                    "{}",
350                    style(messages::deleting_remote_branch(branch)).yellow()
351                );
352                match git::git_command(
353                    &["push", "origin", &format!(":{}", branch)],
354                    Some(&main_repo),
355                    false,
356                    true,
357                ) {
358                    Ok(r) if r.returncode == 0 => {
359                        println!("{} Remote branch deleted\n", style("*").green().bold());
360                    }
361                    _ => {
362                        println!("{} Remote branch deletion failed\n", style("!").yellow());
363                    }
364                }
365            }
366        }
367    }
368
369    // Post-delete hooks
370    hook_ctx.insert("event".into(), "worktree.post_delete".into());
371    let _ = hooks::run_hooks(
372        "worktree.post_delete",
373        &hook_ctx,
374        Some(&main_repo),
375        Some(&main_repo),
376    );
377    let _ = registry::update_last_seen(&main_repo);
378
379    Ok(())
380}
381
382/// Resolve delete target to (worktree_path, branch_name).
383fn resolve_delete_target(
384    target: Option<&str>,
385    main_repo: &Path,
386    lookup_mode: Option<&str>,
387) -> Result<(PathBuf, Option<String>)> {
388    let target = target.map(|t| t.to_string()).unwrap_or_else(|| {
389        std::env::current_dir()
390            .unwrap_or_default()
391            .to_string_lossy()
392            .to_string()
393    });
394
395    let target_path = PathBuf::from(&target);
396
397    // Check if it's a filesystem path
398    if target_path.exists() {
399        let resolved = target_path.canonicalize().unwrap_or(target_path);
400        let branch = super::helpers::get_branch_for_worktree(main_repo, &resolved);
401        return Ok((resolved, branch));
402    }
403
404    // Try branch lookup (skip if lookup_mode is "worktree")
405    if lookup_mode != Some("worktree") {
406        if let Some(path) = git::find_worktree_by_intended_branch(main_repo, &target)? {
407            return Ok((path, Some(target)));
408        }
409    }
410
411    // Try worktree name lookup (skip if lookup_mode is "branch")
412    if lookup_mode != Some("branch") {
413        if let Some(path) = git::find_worktree_by_name(main_repo, &target)? {
414            let branch = super::helpers::get_branch_for_worktree(main_repo, &path);
415            return Ok((path, branch));
416        }
417    }
418
419    Err(CwError::WorktreeNotFound(messages::worktree_not_found(
420        &target,
421    )))
422}
423
424/// Sync worktree with base branch.
425pub fn sync_worktree(
426    target: Option<&str>,
427    all: bool,
428    _fetch_only: bool,
429    ai_merge: bool,
430    lookup_mode: Option<&str>,
431) -> Result<()> {
432    let repo = git::get_repo_root(None)?;
433
434    // Fetch first
435    println!("{}", style("Fetching updates from remote...").yellow());
436    let fetch_result = git::git_command(&["fetch", "--all", "--prune"], Some(&repo), false, true)?;
437    if fetch_result.returncode != 0 {
438        println!(
439            "{} Fetch failed or no remote configured\n",
440            style("!").yellow()
441        );
442    }
443
444    if _fetch_only {
445        println!("{} Fetch complete\n", style("*").green().bold());
446        return Ok(());
447    }
448
449    // Determine worktrees to sync
450    let worktrees_to_sync = if all {
451        let all_wt = git::parse_worktrees(&repo)?;
452        all_wt
453            .into_iter()
454            .filter(|(b, _)| b != "(detached)")
455            .map(|(b, p)| {
456                let branch = git::normalize_branch_name(&b).to_string();
457                (branch, p)
458            })
459            .collect::<Vec<_>>()
460    } else {
461        let resolved = resolve_worktree_target(target, lookup_mode)?;
462        vec![(resolved.branch, resolved.path)]
463    };
464
465    for (branch, wt_path) in &worktrees_to_sync {
466        let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch);
467        let base_branch = git::get_config(&base_key, Some(&repo));
468
469        if let Some(base) = base_branch {
470            println!("\n{}", style("Syncing worktree:").cyan().bold());
471            println!("  Branch: {}", style(branch).green());
472            println!("  Base:   {}", style(&base).green());
473            println!("  Path:   {}\n", style(wt_path.display()).blue());
474
475            // Determine rebase target (fetch already done above)
476            let rebase_target = {
477                let origin_base = format!("origin/{}", base);
478                if git::branch_exists(&origin_base, Some(wt_path)) {
479                    origin_base
480                } else {
481                    base.clone()
482                }
483            };
484
485            println!(
486                "{}",
487                style(messages::rebase_in_progress(branch, &rebase_target)).yellow()
488            );
489
490            match git::git_command(&["rebase", &rebase_target], Some(wt_path), false, true) {
491                Ok(r) if r.returncode == 0 => {
492                    println!("{} Rebase successful\n", style("*").green().bold());
493                }
494                _ => {
495                    if ai_merge {
496                        let conflicts = git::git_command(
497                            &["diff", "--name-only", "--diff-filter=U"],
498                            Some(wt_path),
499                            false,
500                            true,
501                        )
502                        .ok()
503                        .and_then(|r| {
504                            if r.returncode == 0 && !r.stdout.trim().is_empty() {
505                                Some(r.stdout.trim().to_string())
506                            } else {
507                                None
508                            }
509                        });
510
511                        let _ =
512                            git::git_command(&["rebase", "--abort"], Some(wt_path), false, false);
513
514                        let conflict_list = conflicts.as_deref().unwrap_or("(unknown)");
515                        let prompt = format!(
516                            "Resolve merge conflicts in this repository. The rebase of '{}' onto '{}' \
517                             failed with conflicts in: {}\n\
518                             Please examine the conflicted files and resolve them.",
519                            branch, rebase_target, conflict_list
520                        );
521
522                        println!(
523                            "\n{} Launching AI to resolve conflicts for '{}'...\n",
524                            style("*").cyan().bold(),
525                            branch
526                        );
527                        let _ = super::ai_tools::launch_ai_tool(
528                            wt_path,
529                            None,
530                            false,
531                            Some(&prompt),
532                            None,
533                        );
534                    } else {
535                        // Abort rebase on failure
536                        let _ =
537                            git::git_command(&["rebase", "--abort"], Some(wt_path), false, false);
538                        println!(
539                            "{} Rebase failed for '{}'. Resolve conflicts manually.\n\
540                             Tip: Use --ai-merge flag to get AI assistance with conflicts\n",
541                            style("!").yellow(),
542                            branch
543                        );
544                    }
545                }
546            }
547        } else {
548            // No base branch metadata — try origin/branch
549            let origin_ref = format!("origin/{}", branch);
550            if git::branch_exists(&origin_ref, Some(wt_path)) {
551                println!("\n{}", style("Syncing worktree:").cyan().bold());
552                println!("  Branch: {}", style(branch).green());
553                println!("  Path:   {}\n", style(wt_path.display()).blue());
554
555                println!(
556                    "{}",
557                    style(messages::rebase_in_progress(branch, &origin_ref)).yellow()
558                );
559
560                match git::git_command(&["rebase", &origin_ref], Some(wt_path), false, true) {
561                    Ok(r) if r.returncode == 0 => {
562                        println!("{} Rebase successful\n", style("*").green().bold());
563                    }
564                    _ => {
565                        let _ =
566                            git::git_command(&["rebase", "--abort"], Some(wt_path), false, false);
567                        println!(
568                            "{} Rebase failed for '{}'. Resolve conflicts manually.\n",
569                            style("!").yellow(),
570                            branch
571                        );
572                    }
573                }
574            }
575        }
576    }
577
578    Ok(())
579}